瀏覽代碼

Merge branch 'main' into feat/x-ui-sh-migratedb

Sanaei 16 小時之前
父節點
當前提交
e3deac536f

+ 1 - 0
.dockerignore

@@ -6,3 +6,4 @@ db
 cert
 pgdata
 *.db
+*.dump

+ 1 - 0
.gitignore

@@ -38,6 +38,7 @@ Thumbs.db
 x-ui.db
 x-ui.db-shm
 x-ui.db-wal
+*.dump
 
 # Ignore Docker specific files
 docker-compose.override.yml

+ 218 - 0
database/dump_sqlite.go

@@ -0,0 +1,218 @@
+package database
+
+import (
+	"database/sql"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// DumpSQLite writes a portable SQL text dump of the SQLite database at srcPath
+// to outPath. The output mirrors the `sqlite3 .dump` format (schema + data +
+// indexes wrapped in a transaction), so it can be rebuilt with RestoreSQLite or
+// loaded by the sqlite3 CLI. The source database is opened read-only in effect
+// and left untouched.
+func DumpSQLite(srcPath, outPath string) error {
+	data, err := DumpSQLiteToBytes(srcPath)
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(outPath, data, 0o644)
+}
+
+// DumpSQLiteToBytes builds the same `sqlite3 .dump`-style SQL text as DumpSQLite
+// but returns it in memory, which the panel uses to stream a migration download.
+func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
+	if _, err := os.Stat(srcPath); err != nil {
+		return nil, fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
+	}
+
+	gdb, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return nil, err
+	}
+	sqlDB, err := gdb.DB()
+	if err != nil {
+		return nil, err
+	}
+	defer sqlDB.Close()
+
+	var b strings.Builder
+	b.WriteString("PRAGMA foreign_keys=OFF;\n")
+	b.WriteString("BEGIN TRANSACTION;\n")
+
+	// Tables in creation order, each followed by its data.
+	type object struct{ name, ddl string }
+	var tables []object
+	rows, err := sqlDB.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
+	if err != nil {
+		return nil, err
+	}
+	for rows.Next() {
+		var o object
+		if err := rows.Scan(&o.name, &o.ddl); err != nil {
+			rows.Close()
+			return nil, err
+		}
+		tables = append(tables, o)
+	}
+	if err := rows.Err(); err != nil {
+		rows.Close()
+		return nil, err
+	}
+	rows.Close()
+
+	for _, t := range tables {
+		b.WriteString(t.ddl)
+		b.WriteString(";\n")
+		if err := dumpTableData(sqlDB, t.name, &b); err != nil {
+			return nil, err
+		}
+	}
+
+	// AUTOINCREMENT bookkeeping, restored verbatim like the sqlite3 CLI does.
+	if sqliteTableExists(sqlDB, "sqlite_sequence") {
+		b.WriteString("DELETE FROM sqlite_sequence;\n")
+		if err := dumpTableData(sqlDB, "sqlite_sequence", &b); err != nil {
+			return nil, err
+		}
+	}
+
+	// Indexes, triggers and views after the data is in place.
+	rows2, err := sqlDB.Query(`SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
+	if err != nil {
+		return nil, err
+	}
+	for rows2.Next() {
+		var ddl string
+		if err := rows2.Scan(&ddl); err != nil {
+			rows2.Close()
+			return nil, err
+		}
+		b.WriteString(ddl)
+		b.WriteString(";\n")
+	}
+	if err := rows2.Err(); err != nil {
+		rows2.Close()
+		return nil, err
+	}
+	rows2.Close()
+
+	b.WriteString("COMMIT;\n")
+
+	return []byte(b.String()), nil
+}
+
+// RestoreSQLite rebuilds a SQLite database at dstPath from a SQL text dump
+// produced by DumpSQLite (or `sqlite3 .dump`). dstPath must not already exist so
+// an existing database is never clobbered silently.
+func RestoreSQLite(dumpPath, dstPath string) error {
+	script, err := os.ReadFile(dumpPath)
+	if err != nil {
+		return err
+	}
+	if _, err := os.Stat(dstPath); err == nil {
+		return fmt.Errorf("destination already exists: %s", dstPath)
+	}
+
+	gdb, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return err
+	}
+	sqlDB, err := gdb.DB()
+	if err != nil {
+		return err
+	}
+
+	// mattn/go-sqlite3 executes every statement in a multi-statement string.
+	if _, err := sqlDB.Exec(string(script)); err != nil {
+		sqlDB.Close()
+		os.Remove(dstPath)
+		return fmt.Errorf("restore failed: %w", err)
+	}
+	return sqlDB.Close()
+}
+
+// dumpTableData appends one INSERT statement per row of table to b.
+func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
+	rows, err := db.Query(`SELECT * FROM "` + table + `"`)
+	if err != nil {
+		return err
+	}
+	defer rows.Close()
+
+	cols, err := rows.Columns()
+	if err != nil {
+		return err
+	}
+	n := len(cols)
+	prefix := `INSERT INTO "` + table + `" VALUES(`
+
+	for rows.Next() {
+		vals := make([]any, n)
+		ptrs := make([]any, n)
+		for i := range vals {
+			ptrs[i] = &vals[i]
+		}
+		if err := rows.Scan(ptrs...); err != nil {
+			return err
+		}
+		b.WriteString(prefix)
+		for i, v := range vals {
+			if i > 0 {
+				b.WriteByte(',')
+			}
+			b.WriteString(sqliteLiteral(v))
+		}
+		b.WriteString(");\n")
+	}
+	return rows.Err()
+}
+
+// sqliteLiteral renders a scanned column value as a SQLite SQL literal.
+func sqliteLiteral(v any) string {
+	switch x := v.(type) {
+	case nil:
+		return "NULL"
+	case int64:
+		return strconv.FormatInt(x, 10)
+	case float64:
+		return strconv.FormatFloat(x, 'g', -1, 64)
+	case bool:
+		if x {
+			return "1"
+		}
+		return "0"
+	case string:
+		return quoteSQLiteText(x)
+	case []byte:
+		if utf8.Valid(x) {
+			return quoteSQLiteText(string(x))
+		}
+		var sb strings.Builder
+		sb.WriteString("X'")
+		for _, c := range x {
+			fmt.Fprintf(&sb, "%02x", c)
+		}
+		sb.WriteByte('\'')
+		return sb.String()
+	default:
+		return quoteSQLiteText(fmt.Sprintf("%v", x))
+	}
+}
+
+func quoteSQLiteText(s string) string {
+	return "'" + strings.ReplaceAll(s, "'", "''") + "'"
+}
+
+func sqliteTableExists(db *sql.DB, name string) bool {
+	var found string
+	err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
+	return err == nil
+}

+ 137 - 0
database/dump_sqlite_test.go

@@ -0,0 +1,137 @@
+package database
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// TestCopyAllModelsIntoSQLite exercises the same AutoMigrate + copyTable
+// machinery that ExportPostgresToSQLite relies on, but with a SQLite source so
+// it needs no external database. The Postgres source path uses identical gorm
+// reads (see MigrateData), so this validates the destination-side copy.
+func TestCopyAllModelsIntoSQLite(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.db")
+	dstPath := filepath.Join(dir, "dst.db")
+
+	src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open src: %v", err)
+	}
+	defer closeGorm(src)
+	for _, m := range migrationModels() {
+		if err := src.AutoMigrate(m); err != nil {
+			t.Fatalf("automigrate src %T: %v", m, err)
+		}
+	}
+
+	// Seed a few rows across parent/child tables and a composite-PK table.
+	if err := src.Create(&model.User{Username: "admin", Password: "x"}).Error; err != nil {
+		t.Fatalf("seed user: %v", err)
+	}
+	if err := src.Create(&model.Inbound{UserId: 1, Remark: "in", Port: 443, Protocol: "vless", Tag: "inbound-443"}).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	if err := src.Create(&xray.ClientTraffic{InboundId: 1, Email: "[email protected]", Enable: true, Up: 10, Down: 20}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open dst: %v", err)
+	}
+	defer closeGorm(dst)
+	if err := copyAllModels(src, dst); err != nil {
+		t.Fatalf("copyAllModels: %v", err)
+	}
+
+	for _, tc := range []struct {
+		model any
+		want  int64
+	}{
+		{&model.User{}, 1},
+		{&model.Inbound{}, 1},
+		{&xray.ClientTraffic{}, 1},
+	} {
+		var got int64
+		if err := dst.Model(tc.model).Count(&got).Error; err != nil {
+			t.Fatalf("count %T: %v", tc.model, err)
+		}
+		if got != tc.want {
+			t.Errorf("%T: got %d rows, want %d", tc.model, got, tc.want)
+		}
+	}
+
+	// Spot-check a copied value survived the round-trip.
+	var ct xray.ClientTraffic
+	if err := dst.Where("email = ?", "[email protected]").First(&ct).Error; err != nil {
+		t.Fatalf("read back traffic: %v", err)
+	}
+	if ct.Up != 10 || ct.Down != 20 || !ct.Enable {
+		t.Errorf("traffic mismatch: %+v", ct)
+	}
+}
+
+// TestDumpAndRestoreSQLiteRoundTrip dumps a seeded SQLite db to .dump text and
+// rebuilds it, asserting the row survives.
+func TestDumpAndRestoreSQLiteRoundTrip(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.db")
+	dumpPath := filepath.Join(dir, "out.dump")
+	dstPath := filepath.Join(dir, "rebuilt.db")
+
+	src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open src: %v", err)
+	}
+	if err := src.AutoMigrate(&model.Setting{}); err != nil {
+		t.Fatalf("automigrate: %v", err)
+	}
+	if err := src.Create(&model.Setting{Key: "secret", Value: "o'brien \"quote\""}).Error; err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	if sqlDB, _ := src.DB(); sqlDB != nil {
+		sqlDB.Close()
+	}
+
+	if err := DumpSQLite(srcPath, dumpPath); err != nil {
+		t.Fatalf("DumpSQLite: %v", err)
+	}
+	if fi, err := os.Stat(dumpPath); err != nil || fi.Size() == 0 {
+		t.Fatalf("dump missing/empty: %v", err)
+	}
+	if err := RestoreSQLite(dumpPath, dstPath); err != nil {
+		t.Fatalf("RestoreSQLite: %v", err)
+	}
+
+	dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open dst: %v", err)
+	}
+	defer closeGorm(dst)
+	var s model.Setting
+	if err := dst.Where("key = ?", "secret").First(&s).Error; err != nil {
+		t.Fatalf("read back: %v", err)
+	}
+	if s.Value != "o'brien \"quote\"" {
+		t.Errorf("value mismatch after round-trip: %q", s.Value)
+	}
+}
+
+// closeGorm closes the underlying *sql.DB so Windows can delete the temp file.
+func closeGorm(db *gorm.DB) {
+	if db == nil {
+		return
+	}
+	if s, err := db.DB(); err == nil {
+		s.Close()
+	}
+}

+ 85 - 0
database/migrate_data.go

@@ -86,6 +86,15 @@ func MigrateData(srcPath, dstDSN string) error {
 		}
 	}
 
+	// Empty the destination tables so the migration is idempotent: a fresh
+	// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
+	// panel start, and a partially-failed earlier run leaves rows behind. Either
+	// way a plain INSERT with explicit ids would collide on users_pkey, so clear
+	// our tables (only) before copying.
+	if err := truncatePostgresTables(dst, migrationModels()); err != nil {
+		return fmt.Errorf("clear destination tables: %w", err)
+	}
+
 	totalRows := 0
 	for _, m := range migrationModels() {
 		n, err := copyTable(src, dst, m)
@@ -105,6 +114,62 @@ func MigrateData(srcPath, dstDSN string) error {
 	return nil
 }
 
+// ExportPostgresToSQLite copies every row from the PostgreSQL database described
+// by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
+// MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
+// dstPath is created/overwritten; the PostgreSQL source is left untouched.
+func ExportPostgresToSQLite(srcDSN, dstPath string) error {
+	if srcDSN == "" {
+		return errors.New("source DSN is required")
+	}
+	if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
+		return err
+	}
+	// Start from an empty file so AutoMigrate creates the canonical schema.
+	if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+
+	src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return fmt.Errorf("open postgres source: %w", err)
+	}
+	srcSQL, err := src.DB()
+	if err != nil {
+		return err
+	}
+	defer srcSQL.Close()
+
+	// No WAL: keep all data in the main file so it is complete once closed.
+	dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return fmt.Errorf("open sqlite destination: %w", err)
+	}
+	dstSQL, err := dst.DB()
+	if err != nil {
+		return err
+	}
+	defer dstSQL.Close()
+
+	return copyAllModels(src, dst)
+}
+
+// copyAllModels (re)creates the schema on dst and copies every migrated table
+// from src to dst in FK-safe order. src/dst may be any gorm backend.
+func copyAllModels(src, dst *gorm.DB) error {
+	for _, m := range migrationModels() {
+		if err := dst.AutoMigrate(m); err != nil {
+			return fmt.Errorf("AutoMigrate %T: %w", m, err)
+		}
+	}
+	for _, m := range migrationModels() {
+		if _, err := copyTable(src, dst, m); err != nil {
+			return fmt.Errorf("copy %T: %w", m, err)
+		}
+	}
+	return nil
+}
+
 func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
 	const batchSize = 500
 
@@ -157,6 +222,26 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
 	return total, nil
 }
 
+// truncatePostgresTables empties every migrated table on dst in a single
+// statement, resetting identity sequences. CASCADE covers the inbound/client
+// foreign keys regardless of insertion order. Only the panel's own tables are
+// touched, never the rest of the schema.
+func truncatePostgresTables(dst *gorm.DB, models []any) error {
+	tables := make([]string, 0, len(models))
+	for _, m := range models {
+		stmt := &gorm.Statement{DB: dst}
+		if err := stmt.Parse(m); err != nil {
+			return err
+		}
+		tables = append(tables, `"`+stmt.Schema.Table+`"`)
+	}
+	if len(tables) == 0 {
+		return nil
+	}
+	log.Println("Clearing destination tables...")
+	return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
+}
+
 // resetPostgresSequences advances each migrated table's id sequence past MAX(id),
 // otherwise the next INSERT-without-id would clash with copied rows.
 func resetPostgresSequences(dst *gorm.DB) error {

+ 5 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -307,6 +307,11 @@ export const sections: readonly Section[] = [
         path: '/panel/api/server/getDb',
         summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getMigration',
+        summary: 'Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.',
+      },
       {
         method: 'GET',
         path: '/panel/api/server/getNewUUID',

+ 14 - 0
frontend/src/pages/index/BackupModal.tsx

@@ -25,6 +25,10 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
     window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
   }
 
+  function exportMigration() {
+    window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getMigration';
+  }
+
   function importDb() {
     const fileInput = document.createElement('input');
     fileInput.type = 'file';
@@ -82,6 +86,16 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
           <Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
         </div>
 
+        <div className="backup-item">
+          <div className="backup-meta">
+            <div className="backup-title">{t('pages.index.migrationDownload')}</div>
+            <div className="backup-description">
+              {isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
+            </div>
+          </div>
+          <Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
+        </div>
+
         <div className="backup-item">
           <div className="backup-meta">
             <div className="backup-title">{t('pages.index.importDatabase')}</div>

+ 31 - 8
main.go

@@ -466,8 +466,14 @@ func main() {
 	migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
 	var migrateDsn string
 	var migrateSrc string
+	var migrateDump string
+	var migrateRestore string
+	var migrateOut string
 	migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
 	migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
+	migrateDbCmd.StringVar(&migrateDump, "dump", "", "Write a portable SQL text dump of --src to this file (.db -> .dump)")
+	migrateDbCmd.StringVar(&migrateRestore, "restore", "", "Rebuild a SQLite database from this SQL text dump (.dump -> .db); requires --out")
+	migrateDbCmd.StringVar(&migrateOut, "out", "", "Destination SQLite file for --restore (must not already exist)")
 
 	settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
 	var port int
@@ -512,7 +518,7 @@ func main() {
 		fmt.Println("Commands:")
 		fmt.Println("    run            run web panel")
 		fmt.Println("    migrate        migrate form other/old x-ui")
-		fmt.Println("    migrate-db     copy data from the SQLite file into a PostgreSQL database")
+		fmt.Println("    migrate-db     SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
 		fmt.Println("    setting        set settings")
 	}
 
@@ -541,13 +547,30 @@ func main() {
 		if src == "" {
 			src = config.GetDBPath()
 		}
-		if migrateDsn == "" {
-			fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable")
-			return
-		}
-		if err := database.MigrateData(src, migrateDsn); err != nil {
-			fmt.Println("migration failed:", err)
-			os.Exit(1)
+		switch {
+		case migrateDump != "":
+			if err := database.DumpSQLite(src, migrateDump); err != nil {
+				fmt.Println("dump failed:", err)
+				os.Exit(1)
+			}
+			fmt.Printf("Dumped %s -> %s\n", src, migrateDump)
+		case migrateRestore != "":
+			if migrateOut == "" {
+				fmt.Println("--out is required when using --restore: the destination .db path (must not exist)")
+				return
+			}
+			if err := database.RestoreSQLite(migrateRestore, migrateOut); err != nil {
+				fmt.Println("restore failed:", err)
+				os.Exit(1)
+			}
+			fmt.Printf("Restored %s -> %s\n", migrateRestore, migrateOut)
+		case migrateDsn != "":
+			if err := database.MigrateData(src, migrateDsn); err != nil {
+				fmt.Println("migration failed:", err)
+				os.Exit(1)
+			}
+		default:
+			fmt.Println("nothing to do: pass --dump <file>, --restore <file> --out <db>, or --dsn <postgres-dsn>")
 		}
 	case "setting":
 		err := settingCmd.Parse(os.Args[2:])

+ 19 - 0
web/controller/server.go

@@ -53,6 +53,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
 	g.GET("/getConfigJson", a.getConfigJson)
 	g.GET("/getDb", a.getDb)
+	g.GET("/getMigration", a.getMigration)
 	g.GET("/getNewUUID", a.getNewUUID)
 	g.GET("/getWebCertFiles", a.getWebCertFiles)
 	g.GET("/getNewX25519Cert", a.getNewX25519Cert)
@@ -300,6 +301,24 @@ func (a *ServerController) getDb(c *gin.Context) {
 	c.Writer.Write(db)
 }
 
+// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
+// .db SQLite database on PostgreSQL, so the data can seed the other backend.
+func (a *ServerController) getMigration(c *gin.Context) {
+	data, filename, err := a.serverService.GetMigration()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
+		return
+	}
+	if !filenameRegex.MatchString(filename) {
+		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
+		return
+	}
+
+	c.Header("Content-Type", "application/octet-stream")
+	c.Header("Content-Disposition", "attachment; filename="+filename)
+	c.Writer.Write(data)
+}
+
 // importDB imports a database file and restarts the Xray service.
 func (a *ServerController) importDB(c *gin.Context) {
 	file, _, err := c.Request.FormFile("db")

+ 35 - 0
web/service/server.go

@@ -1156,6 +1156,41 @@ func (s *ServerService) GetDb() ([]byte, error) {
 	return fileContents, nil
 }
 
+// GetMigration produces a cross-engine migration file plus its filename: on a
+// SQLite panel it returns a portable .dump (SQL text), and on a PostgreSQL panel
+// it returns a .db SQLite database built from the live data. Either output can
+// then seed a panel running on the other backend.
+func (s *ServerService) GetMigration() ([]byte, string, error) {
+	if database.IsPostgres() {
+		tmp, err := os.CreateTemp("", "x-ui-migration-*.db")
+		if err != nil {
+			return nil, "", err
+		}
+		tmpPath := tmp.Name()
+		tmp.Close()
+		defer os.Remove(tmpPath)
+
+		if err := database.ExportPostgresToSQLite(config.GetDBDSN(), tmpPath); err != nil {
+			return nil, "", err
+		}
+		data, err := os.ReadFile(tmpPath)
+		if err != nil {
+			return nil, "", err
+		}
+		return data, "x-ui.db", nil
+	}
+
+	// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
+	if err := database.Checkpoint(); err != nil {
+		return nil, "", err
+	}
+	data, err := database.DumpSQLiteToBytes(config.GetDBPath())
+	if err != nil {
+		return nil, "", err
+	}
+	return data, "x-ui.dump", nil
+}
+
 func (s *ServerService) ImportDB(file multipart.File) error {
 	if database.IsPostgres() {
 		return s.importPostgresDB(file)

+ 4 - 1
web/translation/ar-EG.json

@@ -277,7 +277,10 @@
       "getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات",
       "backupPostgresNote": "تعمل هذه اللوحة على PostgreSQL. يقوم «النسخ الاحتياطي» بتنزيل أرشيف pg_dump (.dump)، و«الاستعادة» تعيد تحميله عبر pg_restore. يجب أن تكون أدوات عميل PostgreSQL (pg_dump و pg_restore) مثبَّتة على الخادم.",
       "exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
-      "importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية."
+      "importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
+      "migrationDownload": "تنزيل ملف الترحيل",
+      "migrationDownloadDesc": "انقر لتنزيل تصدير .dump محمول (نص SQL) لقاعدة بيانات SQLite الخاصة بك.",
+      "migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
     },
     "inbounds": {
       "title": "الواردات",

+ 4 - 1
web/translation/en-US.json

@@ -277,7 +277,10 @@
       "getConfigError": "An error occurred while retrieving the config file.",
       "backupPostgresNote": "This panel runs on PostgreSQL. Back Up downloads a pg_dump archive (.dump) and Restore loads it back with pg_restore. The server needs the PostgreSQL client tools (pg_dump and pg_restore) installed.",
       "exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
-      "importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data."
+      "importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data.",
+      "migrationDownload": "Download Migration",
+      "migrationDownloadDesc": "Click to download a portable .dump (SQL text) export of your SQLite database.",
+      "migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
     },
     "inbounds": {
       "title": "Inbounds",

+ 4 - 1
web/translation/es-ES.json

@@ -277,7 +277,10 @@
       "getConfigError": "Ocurrió un error al obtener el archivo de configuración",
       "backupPostgresNote": "Este panel funciona con PostgreSQL. «Copia de seguridad» descarga un archivo pg_dump (.dump) y «Restaurar» lo vuelve a cargar con pg_restore. El servidor necesita tener instaladas las herramientas cliente de PostgreSQL (pg_dump y pg_restore).",
       "exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
-      "importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales."
+      "importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales.",
+      "migrationDownload": "Descargar migración",
+      "migrationDownloadDesc": "Haz clic para descargar una exportación portable .dump (texto SQL) de tu base de datos SQLite.",
+      "migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite."
     },
     "inbounds": {
       "title": "Entradas",

+ 4 - 1
web/translation/fa-IR.json

@@ -277,7 +277,10 @@
       "getConfigError": "خطا در دریافت فایل پیکربندی",
       "backupPostgresNote": "این پنل روی PostgreSQL اجرا می‌شود. «پشتیبان‌گیری» یک آرشیو pg_dump (.dump) دانلود می‌کند و «بازیابی» آن را با pg_restore بازمی‌گرداند. سرور باید ابزارهای کلاینت PostgreSQL (pg_dump و pg_restore) را نصب داشته باشد.",
       "exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
-      "importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند."
+      "importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند.",
+      "migrationDownload": "دانلود فایل مهاجرت",
+      "migrationDownloadDesc": "برای دانلود یک خروجی قابل‌حمل ‎.dump (متن SQL) از پایگاه‌دادهٔ SQLite خود کلیک کنید.",
+      "migrationDownloadPgDesc": "برای دانلود یک پایگاه‌دادهٔ SQLite با پسوند ‎.db که از داده‌های PostgreSQL شما ساخته می‌شود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
     },
     "inbounds": {
       "title": "ورودی‌ها",

+ 4 - 1
web/translation/id-ID.json

@@ -277,7 +277,10 @@
       "getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi",
       "backupPostgresNote": "Panel ini berjalan di PostgreSQL. «Cadangkan» mengunduh arsip pg_dump (.dump) dan «Pulihkan» memuatnya kembali dengan pg_restore. Server memerlukan alat klien PostgreSQL (pg_dump dan pg_restore) terpasang.",
       "exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
-      "importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini."
+      "importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini.",
+      "migrationDownload": "Unduh migrasi",
+      "migrationDownloadDesc": "Klik untuk mengunduh ekspor .dump (teks SQL) portabel dari basis data SQLite Anda.",
+      "migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
     },
     "inbounds": {
       "title": "Inbound",

+ 4 - 1
web/translation/ja-JP.json

@@ -277,7 +277,10 @@
       "getConfigError": "設定ファイルの取得中にエラーが発生しました",
       "backupPostgresNote": "このパネルは PostgreSQL で動作しています。「バックアップ」は pg_dump アーカイブ (.dump) をダウンロードし、「復元」は pg_restore で読み込み直します。サーバーに PostgreSQL クライアントツール (pg_dump と pg_restore) がインストールされている必要があります。",
       "exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
-      "importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。"
+      "importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
+      "migrationDownload": "移行ファイルをダウンロード",
+      "migrationDownloadDesc": "SQLite データベースのポータブルな .dump(SQL テキスト)エクスポートをダウンロードするにはクリックします。",
+      "migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
     },
     "inbounds": {
       "title": "インバウンド",

+ 4 - 1
web/translation/pt-BR.json

@@ -277,7 +277,10 @@
       "getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração",
       "backupPostgresNote": "Este painel é executado em PostgreSQL. «Backup» baixa um arquivo pg_dump (.dump) e «Restaurar» o recarrega com pg_restore. O servidor precisa ter as ferramentas cliente do PostgreSQL (pg_dump e pg_restore) instaladas.",
       "exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
-      "importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais."
+      "importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais.",
+      "migrationDownload": "Baixar migração",
+      "migrationDownloadDesc": "Clique para baixar uma exportação portátil .dump (texto SQL) do seu banco de dados SQLite.",
+      "migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite."
     },
     "inbounds": {
       "title": "Entradas",

+ 4 - 1
web/translation/ru-RU.json

@@ -277,7 +277,10 @@
       "getConfigError": "Произошла ошибка при получении конфигурационного файла",
       "backupPostgresNote": "Эта панель работает на PostgreSQL. «Резервная копия» скачивает архив pg_dump (.dump), а «Восстановление» загружает его обратно через pg_restore. На сервере должны быть установлены клиентские инструменты PostgreSQL (pg_dump и pg_restore).",
       "exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.",
-      "importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные."
+      "importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные.",
+      "migrationDownload": "Скачать файл миграции",
+      "migrationDownloadDesc": "Нажмите, чтобы скачать переносимый экспорт .dump (текст SQL) вашей базы данных SQLite.",
+      "migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite."
     },
     "inbounds": {
       "title": "Входящие",

+ 4 - 1
web/translation/tr-TR.json

@@ -277,7 +277,10 @@
       "getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu",
       "backupPostgresNote": "Bu panel PostgreSQL üzerinde çalışıyor. «Yedekle» bir pg_dump arşivi (.dump) indirir, «Geri Yükle» ise onu pg_restore ile geri yükler. Sunucuda PostgreSQL istemci araçlarının (pg_dump ve pg_restore) kurulu olması gerekir.",
       "exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.",
-      "importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır."
+      "importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
+      "migrationDownload": "Geçiş dosyasını indir",
+      "migrationDownloadDesc": "SQLite veritabanınızın taşınabilir .dump (SQL metni) dışa aktarımını indirmek için tıklayın.",
+      "migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın."
     },
     "inbounds": {
       "title": "Gelenler",

+ 4 - 1
web/translation/uk-UA.json

@@ -277,7 +277,10 @@
       "getConfigError": "Виникла помилка під час отримання файлу конфігурації",
       "backupPostgresNote": "Ця панель працює на PostgreSQL. «Резервна копія» завантажує архів pg_dump (.dump), а «Відновлення» завантажує його назад через pg_restore. На сервері мають бути встановлені клієнтські інструменти PostgreSQL (pg_dump і pg_restore).",
       "exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.",
-      "importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані."
+      "importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані.",
+      "migrationDownload": "Завантажити файл міграції",
+      "migrationDownloadDesc": "Натисніть, щоб завантажити переносний експорт .dump (текст SQL) вашої бази даних SQLite.",
+      "migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite."
     },
     "inbounds": {
       "title": "Вхідні",

+ 4 - 1
web/translation/vi-VN.json

@@ -277,7 +277,10 @@
       "getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình",
       "backupPostgresNote": "Bảng điều khiển này chạy trên PostgreSQL. «Sao lưu» tải xuống một tệp lưu trữ pg_dump (.dump) và «Khôi phục» nạp lại bằng pg_restore. Máy chủ cần cài đặt các công cụ máy khách PostgreSQL (pg_dump và pg_restore).",
       "exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.",
-      "importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại."
+      "importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
+      "migrationDownload": "Tải tệp di trú",
+      "migrationDownloadDesc": "Nhấp để tải xuống bản xuất .dump (văn bản SQL) di động của cơ sở dữ liệu SQLite của bạn.",
+      "migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite."
     },
     "inbounds": {
       "title": "Inbound",

+ 4 - 1
web/translation/zh-CN.json

@@ -277,7 +277,10 @@
       "getConfigError": "检索配置文件时出错",
       "backupPostgresNote": "此面板运行在 PostgreSQL 上。「备份」会下载一个 pg_dump 归档(.dump),「恢复」会通过 pg_restore 重新载入。服务器需要安装 PostgreSQL 客户端工具(pg_dump 和 pg_restore)。",
       "exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump)下载到您的设备。",
-      "importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。"
+      "importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。",
+      "migrationDownload": "下载迁移文件",
+      "migrationDownloadDesc": "点击下载当前 SQLite 数据库的可移植 .dump(SQL 文本)导出文件。",
+      "migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。"
     },
     "inbounds": {
       "title": "入站",

+ 4 - 1
web/translation/zh-TW.json

@@ -277,7 +277,10 @@
       "getConfigError": "檢索設定檔時發生錯誤",
       "backupPostgresNote": "此面板執行於 PostgreSQL 上。「備份」會下載一個 pg_dump 封存檔(.dump),「還原」會透過 pg_restore 重新載入。伺服器需要安裝 PostgreSQL 用戶端工具(pg_dump 與 pg_restore)。",
       "exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump)下載到您的裝置。",
-      "importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。"
+      "importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。",
+      "migrationDownload": "下載遷移檔案",
+      "migrationDownloadDesc": "點擊下載目前 SQLite 資料庫的可攜式 .dump(SQL 文字)匯出檔。",
+      "migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。"
     },
     "inbounds": {
       "title": "入站",

+ 1 - 1
x-ui.sh

@@ -2690,7 +2690,7 @@ migrate_to_postgres() {
     echo ""
     echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}"
     echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}"
-    echo -e "${yellow}The destination PostgreSQL database must be empty.${plain}"
+    echo -e "${red}Any existing panel tables in the destination will be cleared and overwritten.${plain}"
     confirm "Continue?" "n" || return 0
 
     local dsn="" pg_mode