diff --git a/backend/api/api.go b/backend/api/api.go
index 20a2960..798d135 100644
--- a/backend/api/api.go
+++ b/backend/api/api.go
@@ -2,12 +2,14 @@ package api
import (
"encoding/json"
+ "s-ui/database"
"s-ui/logger"
"s-ui/service"
"s-ui/util"
"s-ui/util/common"
"strconv"
"strings"
+ "time"
"github.com/gin-gonic/gin"
)
@@ -109,6 +111,15 @@ func (a *APIHandler) postHandler(c *gin.Context) {
link := c.Request.FormValue("link")
result, _, err := util.GetOutbound(link, 0)
jsonObj(c, result, err)
+ case "importdb":
+ file, _, err := c.Request.FormFile("db")
+ if err != nil {
+ jsonMsg(c, "", err)
+ return
+ }
+ defer file.Close()
+ err = database.ImportDB(file)
+ jsonMsg(c, "", err)
default:
jsonMsg(c, "failed", common.NewError("unknown action: ", action))
}
@@ -188,6 +199,16 @@ func (a *APIHandler) getHandler(c *gin.Context) {
options := c.Query("o")
keypair := a.ServerService.GenKeypair(kType, options)
jsonObj(c, keypair, nil)
+ case "getdb":
+ exclude := c.Query("exclude")
+ db, err := database.GetDb(exclude)
+ if err != nil {
+ jsonMsg(c, "", err)
+ return
+ }
+ c.Header("Content-Type", "application/octet-stream")
+ c.Header("Content-Disposition", "attachment; filename=s-ui_"+time.Now().Format("20060102-150405")+".db")
+ c.Writer.Write(db)
default:
jsonMsg(c, "failed", common.NewError("unknown action: ", action))
}
diff --git a/backend/database/backup.go b/backend/database/backup.go
new file mode 100644
index 0000000..799b8b1
--- /dev/null
+++ b/backend/database/backup.go
@@ -0,0 +1,271 @@
+package database
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "os"
+ "path/filepath"
+ "s-ui/cmd/migration"
+ "s-ui/config"
+ "s-ui/database/model"
+ "s-ui/logger"
+ "s-ui/util/common"
+ "strings"
+ "syscall"
+ "time"
+
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func GetDb(exclude string) ([]byte, error) {
+ exclude_changes, exclude_stats := false, false
+ for _, table := range strings.Split(exclude, ",") {
+ if table == "changes" {
+ exclude_changes = true
+ } else if table == "stats" {
+ exclude_stats = true
+ }
+ }
+
+ dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
+ if err != nil {
+ return nil, err
+ }
+ dbPath := dir + config.GetName() + "_" + time.Now().Format("20060102-200203") + ".db"
+
+ backupDb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
+ if err != nil {
+ return nil, err
+ }
+
+ err = backupDb.AutoMigrate(
+ &model.Setting{},
+ &model.Tls{},
+ &model.Inbound{},
+ &model.Outbound{},
+ &model.Endpoint{},
+ &model.User{},
+ &model.Stats{},
+ &model.Client{},
+ &model.Changes{},
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ var settings []model.Setting
+ var tls []model.Tls
+ var inbound []model.Inbound
+ var outbound []model.Outbound
+ var endpoint []model.Endpoint
+ var users []model.User
+ var clients []model.Client
+ var stats []model.Stats
+ var changes []model.Changes
+
+ // Perform scans and handle errors
+ if err := db.Model(&model.Setting{}).Scan(&settings).Error; err != nil {
+ return nil, err
+ }
+ if err := db.Model(&model.Tls{}).Scan(&tls).Error; err != nil {
+ return nil, err
+ }
+ if err := db.Model(&model.Inbound{}).Scan(&inbound).Error; err != nil {
+ return nil, err
+ }
+ if err := db.Model(&model.Outbound{}).Scan(&outbound).Error; err != nil {
+ return nil, err
+ }
+ if err := db.Model(&model.Endpoint{}).Scan(&endpoint).Error; err != nil {
+ return nil, err
+ }
+ if err := db.Model(&model.User{}).Scan(&users).Error; err != nil {
+ return nil, err
+ }
+ if err := db.Model(&model.Client{}).Scan(&clients).Error; err != nil {
+ return nil, err
+ }
+
+ // Save each model
+ for _, mdl := range []interface{}{settings, tls, inbound, outbound, endpoint, users, clients} {
+ if err := backupDb.Save(mdl).Error; err != nil {
+ return nil, err
+ }
+ }
+
+ if !exclude_stats {
+ if err := db.Model(&model.Stats{}).Scan(&stats).Error; err != nil {
+ return nil, err
+ }
+ if err := backupDb.Save(stats).Error; err != nil {
+ return nil, err
+ }
+ }
+ if !exclude_changes {
+ if err := db.Model(&model.Changes{}).Scan(&changes).Error; err != nil {
+ return nil, err
+ }
+ if err := backupDb.Save(changes).Error; err != nil {
+ return nil, err
+ }
+ }
+
+ // Update WAL
+ err = backupDb.Exec("PRAGMA wal_checkpoint;").Error
+ if err != nil {
+ return nil, err
+ }
+
+ bdb, _ := backupDb.DB()
+ bdb.Close()
+
+ // Open the file for reading
+ file, err := os.Open(dbPath)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ defer os.Remove(dbPath)
+
+ // Read the file contents
+ fileContents, err := io.ReadAll(file)
+ if err != nil {
+ return nil, err
+ }
+
+ return fileContents, nil
+}
+
+func ImportDB(file multipart.File) error {
+ // Check if the file is a SQLite database
+ isValidDb, err := 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)
+
+ // Close old DB
+ old_db, _ := db.DB()
+ old_db.Close()
+
+ // 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
+ newDb, err := gorm.Open(sqlite.Open(tempPath), &gorm.Config{})
+ if err != nil {
+ return common.NewErrorf("Error checking db: %v", err)
+ }
+ newDb_db, _ := newDb.DB()
+ newDb_db.Close()
+
+ // 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
+ migration.MigrateDb()
+ err = 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)
+ }
+
+ // Restart app
+ err = SendSighup()
+ if err != nil {
+ return common.NewErrorf("Error restarting app: %v", err)
+ }
+
+ return nil
+}
+
+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
+}
+
+func SendSighup() error {
+ // Get the current process
+ process, err := os.FindProcess(os.Getpid())
+ if err != nil {
+ return err
+ }
+
+ // Send SIGHUP to the current process
+ go func() {
+ time.Sleep(3 * time.Second)
+ err := process.Signal(syscall.SIGHUP)
+ if err != nil {
+ logger.Error("send signal SIGHUP failed:", err)
+ }
+ }()
+ return nil
+}
diff --git a/frontend/src/components/Main.vue b/frontend/src/components/Main.vue
index 9fa97c0..b11da3c 100644
--- a/frontend/src/components/Main.vue
+++ b/frontend/src/components/Main.vue
@@ -1,9 +1,6 @@
-
+
+
@@ -46,6 +43,8 @@
+ {{ $t('main.backup.title') }}
+ {{ $t('basic.log.title') }}
@@ -86,18 +85,8 @@
S-UI
-
- {{ $t('main.info.threads') }}: {{ tilesData.sys?.appThreads }}
- {{ $t('main.info.memory') }}: {{ HumanReadable.sizeFormat(tilesData.sys?.appMem) }}
-
v{{ tilesData.sys?.appVersion }}
-
-
- {{ $t('basic.log.title') + " - S-UI" }}
-
-
-
{{ $t('main.info.uptime') }}
{{ HumanReadable.formatSecond(tilesData.uptime) }}
@@ -166,6 +155,7 @@ import History from '@/components/tiles/History.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { i18n } from '@/locales'
import LogVue from '@/layouts/modals/Logs.vue'
+import Backup from '@/layouts/modals/Backup.vue'
const loading = ref(false)
const menu = ref(false)
@@ -235,17 +225,9 @@ onBeforeUnmount(() => {
stopTimer()
})
-const logModal = ref({
- visible: false,
-})
+const logModal = ref({ visible: false })
-const openLogs = () => {
- logModal.value.visible = true
-}
-
-const closeLogs = () => {
- logModal.value.visible = false
-}
+const backupModal = ref({ visible: false })
const restartSingbox = async () => {
loading.value = true
diff --git a/frontend/src/layouts/modals/Backup.vue b/frontend/src/layouts/modals/Backup.vue
new file mode 100644
index 0000000..e3228b7
--- /dev/null
+++ b/frontend/src/layouts/modals/Backup.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+ {{ $t('main.backup.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('main.backup.backup') }}
+
+
+
+
+
+ {{ $t('main.backup.restore') }}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/layouts/modals/Logs.vue b/frontend/src/layouts/modals/Logs.vue
index 8497981..f9c3ec9 100644
--- a/frontend/src/layouts/modals/Logs.vue
+++ b/frontend/src/layouts/modals/Logs.vue
@@ -6,7 +6,7 @@
{{ $t('basic.log.title') }}
-
+
@@ -48,10 +48,10 @@