diff --git a/.gitignore b/.gitignore index c03fe283..6277cfc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .vscode tmp +backup/ bin/ dist/ x-ui-*.tar.gz @@ -10,4 +11,5 @@ x-ui-*.tar.gz main release/ access.log +error.log .cache diff --git a/README.md b/README.md index 24789665..28b66ffe 100644 --- a/README.md +++ b/README.md @@ -40,28 +40,30 @@ xray panel supporting multi-protocol, **Multi-lang (English,Farsi,Chinese)** - Support https access panel (self-provided domain name + ssl certificate) - Support one-click SSL certificate application and automatic renewal - For more advanced configuration items, please refer to the panel +- Support export/import database from panel ## API routes - `/login` with `PUSH` user data: `{username: '', password: ''}` for login - `/xui/API/inbounds` base for following actions: -| Method | Path | Action | -| :----: | --------------------------------- | ------------------------------------------- | -| `GET` | `"/"` | Get all inbounds | -| `GET` | `"/get/:id"` | Get inbound with inbound.id | -| `POST` | `"/add"` | Add inbound | -| `POST` | `"/del/:id"` | Delete Inbound | -| `POST` | `"/update/:id"` | Update Inbound | -| `POST` | `"/addClient/"` | Add Client to inbound | -| `POST` | `"/:id/delClient/:clientId"` | Delete Client by clientId* | -| `POST` | `"/updateClient/:clientId"` | Update Client by clientId* | -| `POST` | `"/getClientTraffics/:email"` | Get Client's Traffic | -| `POST` | `"/resetAllTraffics"` | Reset traffics of all inbounds | -| `POST` | `"/resetAllClientTraffics/:id"` | Reset inbound clients traffics (-1: all) | -| `POST` | `"/delDepletedClients/:id"` | Delete inbound depleted clients (-1: all) | +| Method | Path | Action | +| :----: | ------------------------------- | ----------------------------------------- | +| `GET` | `"/"` | Get all inbounds | +| `GET` | `"/get/:id"` | Get inbound with inbound.id | +| `POST` | `"/add"` | Add inbound | +| `POST` | `"/del/:id"` | Delete Inbound | +| `POST` | `"/update/:id"` | Update Inbound | +| `POST` | `"/addClient/"` | Add Client to inbound | +| `POST` | `"/:id/delClient/:clientId"` | Delete Client by clientId\* | +| `POST` | `"/updateClient/:clientId"` | Update Client by clientId\* | +| `POST` | `"/getClientTraffics/:email"` | Get Client's Traffic | +| `POST` | `"/resetAllTraffics"` | Reset traffics of all inbounds | +| `POST` | `"/resetAllClientTraffics/:id"` | Reset inbound clients traffics (-1: all) | +| `POST` | `"/delDepletedClients/:id"` | Delete inbound depleted clients (-1: all) | + +\*- The field `clientId` should be filled by: -*- The field `clientId` should be filled by: - `client.id` for VMESS and VLESS - `client.password` for TROJAN - `client.email` for Shadowsocks @@ -94,7 +96,9 @@ bash <(curl -Ls https://raw.githubusercontent.com/alireza0/x-ui/master/install.s ``` ## Install custom version + To install your desired version you can add the version to the end of install command. Example for ver `0.5.2`: + ``` bash <(curl -Ls https://raw.githubusercontent.com/alireza0/x-ui/master/install.sh) 0.5.2 ``` diff --git a/database/db.go b/database/db.go index 57dd2cfb..54d64031 100644 --- a/database/db.go +++ b/database/db.go @@ -1,6 +1,8 @@ package database import ( + "bytes" + "io" "io/fs" "os" "path" @@ -98,3 +100,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 7fdfeeb8..4d2f05d5 100644 --- a/main.go +++ b/main.go @@ -211,8 +211,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 1eaf6ed2..b668551e 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" }}: - Logs - Config - Backup + {{ i18n "pages.index.logs" }} + {{ i18n "pages.index.config" }} + {{ i18n "pages.index.backup" }} @@ -187,6 +187,7 @@ + + + + +

+ + [[ backupModal.description ]] +

+ + + [[ backupModal.exportText ]] + + + [[ backupModal.importText ]] + + +
+ {{template "js" .}} {{template "textModal"}} @@ -338,6 +358,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', @@ -346,6 +389,7 @@ status: new Status(), versionModal, logModal, + backupModal, spinning: false, loadingTip: '{{ i18n "loading"}}', }, @@ -387,7 +431,6 @@ }, }); }, - //here add stop xray function async stopXrayService() { this.loading(true); const msg = await HttpUtil.post('server/stopXrayService'); @@ -396,7 +439,6 @@ return; } }, - //here add restart xray function async restartXrayService() { this.loading(true); const msg = await HttpUtil.post('server/restartXrayService'); @@ -412,20 +454,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/service/inbound.go b/web/service/inbound.go index bde6b1f9..119e497e 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -572,6 +572,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 @@ -582,7 +583,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 @@ -593,6 +595,7 @@ func (s *InboundService) RemoveOrphanedTraffics() { ) `) } + func (s *InboundService) AddClientStat(inboundId int, client *model.Client) error { db := database.GetDB() @@ -611,6 +614,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() @@ -917,3 +921,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 d4048196..813498a0 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 { @@ -393,6 +397,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 5d27497c..7e0a869c 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -89,6 +89,13 @@ "xraySwitchVersionDialog" = "Switch xray version" "xraySwitchVersionDialogDesc" = "Whether to switch the xray version to" "dontRefreshh" = "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 277db594..ce48f647 100644 --- a/web/translation/translate.fa_IR.toml +++ b/web/translation/translate.fa_IR.toml @@ -89,6 +89,13 @@ "xraySwitchVersionDialog" = "تغییر ورژن Xray" "xraySwitchVersionDialogDesc" = "آیا از تغییر ورژن مطمئن هستین" "dontRefreshh" = "در حال نصب ، لطفا رفرش نکنید " +"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 f396340f..e678fc23 100644 --- a/web/translation/translate.zh_Hans.toml +++ b/web/translation/translate.zh_Hans.toml @@ -89,6 +89,13 @@ "xraySwitchVersionDialog" = "切换 xray 版本" "xraySwitchVersionDialogDesc" = "是否切换 xray 版本至" "dontRefreshh" = "安装中,请不要刷新此页面" +"logs" = "日志" +"config" = "配置" +"backup" = "备份" +"backupTitle" = "备份数据库" +"backupDescription" = "请记住在导入新数据库之前进行备份。" +"exportDatabase" = "下载数据库" +"importDatabase" = "上传数据库" [pages.inbounds] "title" = "入站列表"