瀏覽代碼

Merge pull request #347 from hamid-gh98/main

[Feature] import/export database in the panel
Ho3ein 1 年之前
父節點
當前提交
ac31d6d9fb

+ 1 - 0
.gitignore

@@ -1,4 +1,5 @@
 .idea
+.vscode
 tmp
 backup/
 bin/

+ 2 - 2
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)

+ 12 - 0
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
+}

+ 1 - 2
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!")
 }
 

+ 1 - 1
web/assets/css/custom.css

@@ -1,5 +1,5 @@
 #app {
-    height: 100%;
+    height: 100vh;
 }
 
 .ant-space {

+ 8 - 4
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)
-);
+);

+ 24 - 2
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 {

+ 2 - 1
web/html/common/text_modal.html

@@ -4,7 +4,8 @@
          :class="siderDrawer.isDarkTheme ? darkClass : ''"
          :ok-button-props="{attrs:{id:'txt-modal-ok-btn'}}">
     <a-button v-if="!ObjectUtil.isEmpty(txtModal.fileName)" type="primary" style="margin-bottom: 10px;"
-              :href="'data:application/text;charset=utf-8,' + encodeURIComponent(txtModal.content)" :download="txtModal.fileName">
+              :href="'data:application/text;charset=utf-8,' + encodeURIComponent(txtModal.content)"
+              :download="txtModal.fileName">
         {{ i18n "download" }} [[ txtModal.fileName ]]
     </a-button>
     <a-input type="textarea" v-model="txtModal.content"

+ 93 - 11
web/html/xui/index.html

@@ -111,9 +111,9 @@
                     <a-col :sm="24" :md="12">
                         <a-card hoverable :class="siderDrawer.isDarkTheme ? darkClass : ''">
                             {{ i18n "menu.link" }}:
-                            <a-tag color="blue" style="cursor: pointer;" @click="openLogs(20)">Log Reports</a-tag>
-                            <a-tag color="blue" style="cursor: pointer;" @click="openConfig">Config</a-tag>
-                            <a-tag color="blue" style="cursor: pointer;" @click="getBackup">Backup</a-tag>
+                            <a-tag color="blue" style="cursor: pointer;" @click="openLogs(20)">{{ i18n "pages.index.logs" }}</a-tag>
+                            <a-tag color="blue" style="cursor: pointer;" @click="openConfig">{{ i18n "pages.index.config" }}</a-tag>
+                            <a-tag color="blue" style="cursor: pointer;" @click="openBackup">{{ i18n "pages.index.backup" }}</a-tag>
                         </a-card>
                     </a-col>
                     <a-col :sm="24" :md="12">
@@ -188,6 +188,7 @@
             </transition>
         </a-layout-content>
     </a-layout>
+
     <a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}'
              :closable="true" @ok="() => versionModal.visible = false"
              :class="siderDrawer.isDarkTheme ? darkClass : ''"
@@ -201,6 +202,7 @@
             </a-tag>
         </template>
     </a-modal>
+
     <a-modal id="log-modal" v-model="logModal.visible" title="X-UI logs"
              :closable="true" @ok="() => logModal.visible = false" @cancel="() => logModal.visible = false"
              :class="siderDrawer.isDarkTheme ? darkClass : ''"
@@ -227,10 +229,28 @@
                     {{ i18n "download" }} x-ui.log
                 </a-button>
             </a-form-item>
-       </a-form>
+        </a-form>
         <a-input type="textarea" v-model="logModal.logs" disabled="true"
                 :autosize="{ minRows: 10, maxRows: 22}"></a-input>
     </a-modal>
+
+    <a-modal id="backup-modal" v-model="backupModal.visible" :title="backupModal.title"
+            :closable="true" :class="siderDrawer.isDarkTheme ? darkClass : ''"
+            @ok="() => backupModal.hide()" @cancel="() => backupModal.hide()">
+        <p style="color: inherit; font-size: 16px; padding: 4px 2px;">
+            <a-icon type="warning" style="color: inherit; font-size: 20px;"></a-icon>
+            [[ backupModal.description ]]
+        </p>
+        <a-space direction="horizontal" align="center" style="margin-bottom: 10px;">
+            <a-button type="primary" @click="exportDatabase()">
+                [[ backupModal.exportText ]]
+            </a-button>
+            <a-button type="primary" @click="importDatabase()">
+                [[ backupModal.importText ]]
+            </a-button>
+        </a-space>
+    </a-modal>
+
 </a-layout>
 {{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) {

+ 1 - 1
web/html/xui/settings.html

@@ -74,7 +74,7 @@
                                 </a-list>
                             </a-tab-pane>
 
-                            <a-tab-pane key="2" tab='{{ i18n "pages.settings.securitySettings"}}' style="padding-top: 5px;">
+                            <a-tab-pane key="2" tab='{{ i18n "pages.settings.securitySettings"}}' style="padding: 20px;">
                                 <a-tabs default-active-key="sec-1" :class="siderDrawer.isDarkTheme ? darkClass : ''">
                                     <a-tab-pane key="sec-1" tab='{{ i18n "pages.settings.security.admin"}}'>
                                         <a-form :style="siderDrawer.isDarkTheme ? 'color: hsla(0,0%,100%,.65); padding: 20px;': 'background: white; padding: 20px;'">

+ 10 - 1
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()
+}

+ 105 - 1
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")

+ 7 - 0
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"

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

@@ -90,6 +90,13 @@
 "xraySwitchVersionDialog" = "تغییر ورژن"
 "xraySwitchVersionDialogDesc" = "آیا از تغییر ورژن مطمئن هستین"
 "dontRefresh" = "در حال نصب ، لطفا رفرش نکنید "
+"logs" = "گزارش ها"
+"config" = "تنظیمات"
+"backup" = "پشتیبان گیری"
+"backupTitle" = "پشتیبان گیری دیتابیس"
+"backupDescription" = "به یاد داشته باشید که قبل از وارد کردن یک دیتابیس جدید، نسخه پشتیبان تهیه کنید."
+"exportDatabase" = "دانلود دیتابیس"
+"importDatabase" = "آپلود دیتابیس"
 
 [pages.inbounds]
 "title" = "کاربران"

+ 7 - 0
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" = "入站列表"