feat: import SQLite migration dumps through the PostgreSQL panel restore

The SQLite panel's Download Migration produces a portable SQL text dump
advertised as seeding a PostgreSQL panel, but the PostgreSQL Restore only
accepted pg_dump custom archives, so the migration file was rejected with
'Invalid file' even though the upload picker asked for .dump. importDB now
sniffs the upload header: PGDMP archives keep the pg_restore path, while
raw SQLite databases (.db) and SQL text migration dumps are rebuilt,
integrity-checked, and copied into PostgreSQL with the same MigrateData
engine as 'x-ui migrate-db --dsn'. The restore picker accepts .dump/.db on
PostgreSQL and the backup modal texts describe the accepted formats in
every locale.
This commit is contained in:
MHSanaei
2026-07-12 18:04:38 +02:00
parent 44f2f426d8
commit 30b611614b
18 changed files with 200 additions and 49 deletions
+112 -6
View File
@@ -10,6 +10,7 @@ import (
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"mime/multipart"
@@ -1657,18 +1658,51 @@ func parsePgToolVersion(versionOutput string) string {
return pgToolVersionPattern.FindString(versionOutput)
}
func (s *ServerService) importPostgresDB(file multipart.File) error {
header := make([]byte, 5)
if _, err := file.ReadAt(header, 0); err != nil {
return common.NewErrorf("Error reading dump file: %v", err)
const (
pgImportUnknown = iota
pgImportPgDump
pgImportSQLiteDB
pgImportSQLiteDump
)
// sniffPgImportKind classifies an uploaded restore file by its leading bytes:
// a pg_dump custom archive, a raw SQLite database, or a SQLite SQL text dump.
func sniffPgImportKind(header []byte) int {
if bytes.HasPrefix(header, []byte("PGDMP")) {
return pgImportPgDump
}
if string(header) != "PGDMP" {
return common.NewError("Invalid file: expected a PostgreSQL custom-format dump (.dump) created by this panel's Back Up")
if bytes.HasPrefix(header, []byte("SQLite format 3\x00")) {
return pgImportSQLiteDB
}
text := bytes.TrimLeft(bytes.TrimPrefix(header, []byte("\xef\xbb\xbf")), " \t\r\n")
if bytes.HasPrefix(text, []byte("PRAGMA")) || bytes.HasPrefix(text, []byte("BEGIN TRANSACTION")) {
return pgImportSQLiteDump
}
return pgImportUnknown
}
func (s *ServerService) importPostgresDB(file multipart.File) error {
header := make([]byte, 64)
n, err := file.ReadAt(header, 0)
if err != nil && !errors.Is(err, io.EOF) {
return common.NewErrorf("Error reading dump file: %v", err)
}
if _, err := file.Seek(0, 0); err != nil {
return common.NewErrorf("Error resetting file reader: %v", err)
}
switch sniffPgImportKind(header[:n]) {
case pgImportPgDump:
return s.restorePostgresDump(file)
case pgImportSQLiteDB:
return s.migrateSQLiteIntoPostgres(file, false)
case pgImportSQLiteDump:
return s.migrateSQLiteIntoPostgres(file, true)
default:
return common.NewError("Invalid file: expected a PostgreSQL custom-format dump (.dump) from this panel's Back Up, a SQLite database (.db), or a SQLite migration dump from Download Migration")
}
}
func (s *ServerService) restorePostgresDump(file multipart.File) error {
bin, err := exec.LookPath("pg_restore")
if err != nil {
return common.NewError("pg_restore not found on the server; install the postgresql-client package to restore a PostgreSQL database")
@@ -1737,6 +1771,78 @@ func (s *ServerService) importPostgresDB(file multipart.File) error {
return nil
}
func (s *ServerService) migrateSQLiteIntoPostgres(file multipart.File, isSQLDump bool) error {
tempDir, err := os.MkdirTemp("", "x-ui-pg-migrate-*")
if err != nil {
return common.NewErrorf("Error creating temporary folder: %v", err)
}
defer os.RemoveAll(tempDir)
uploadPath := filepath.Join(tempDir, "upload.db")
if isSQLDump {
uploadPath = filepath.Join(tempDir, "upload.dump")
}
if err := saveUploadedFile(file, uploadPath); err != nil {
return common.NewErrorf("Error saving uploaded file: %v", err)
}
dbPath := uploadPath
if isSQLDump {
dbPath = filepath.Join(tempDir, "restored.db")
if err := database.RestoreSQLite(uploadPath, dbPath); err != nil {
return common.NewErrorf("Error rebuilding a SQLite database from the migration dump: %v", err)
}
}
if err := database.ValidateSQLiteDB(dbPath); err != nil {
return common.NewErrorf("Invalid or corrupt db file: %v", err)
}
xrayStopped := true
defer func() {
if xrayStopped {
if errR := s.RestartXrayService(); errR != nil {
logger.Warningf("Failed to restart Xray after DB restore error: %v", errR)
}
}
}()
if errStop := s.StopXrayService(); errStop != nil {
logger.Warningf("Failed to stop Xray before DB restore: %v", errStop)
}
if errClose := database.CloseDB(); errClose != nil {
logger.Warningf("Failed to close existing DB before restore: %v", errClose)
}
migrateErr := database.MigrateData(dbPath, config.GetDBDSN())
if errInit := database.InitDB(config.GetDBPath()); errInit != nil {
return common.NewErrorf("Restore finished but reopening the database failed: %v", errInit)
}
s.inboundService.MigrateDB()
if migrateErr != nil {
return common.NewErrorf("Importing the SQLite data into PostgreSQL failed: %v; the destination tables are cleared on every attempt, so fixing the issue and retrying is safe", migrateErr)
}
xrayStopped = false
if err := s.RestartXrayService(); err != nil {
return common.NewErrorf("Restored DB but failed to start Xray: %v", err)
}
return nil
}
func saveUploadedFile(file multipart.File, dstPath string) error {
dst, err := os.Create(dstPath)
if err != nil {
return err
}
if _, err := io.Copy(dst, file); err != nil {
dst.Close()
return err
}
return dst.Close()
}
// IsValidGeofileName validates that the filename is safe for geofile operations.
// It checks for path traversal attempts and ensures the filename contains only safe characters.
func (s *ServerService) IsValidGeofileName(filename string) bool {
@@ -0,0 +1,45 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
func TestSniffPgImportKind(t *testing.T) {
cases := []struct {
name string
header []byte
want int
}{
{"pg custom archive", []byte("PGDMP\x01\x10\x04"), pgImportPgDump},
{"raw sqlite database", []byte("SQLite format 3\x00rest of header"), pgImportSQLiteDB},
{"sqlite cli dump without pragma", []byte("BEGIN TRANSACTION;\nCREATE TABLE t(i);"), pgImportSQLiteDump},
{"bom and whitespace before pragma", []byte("\xef\xbb\xbf\r\n PRAGMA foreign_keys=OFF;"), pgImportSQLiteDump},
{"plain-format postgres dump", []byte("--\n-- PostgreSQL database dump\n--"), pgImportUnknown},
{"empty file", nil, pgImportUnknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := sniffPgImportKind(tc.header); got != tc.want {
t.Errorf("sniffPgImportKind(%q) = %d, want %d", tc.header, got, tc.want)
}
})
}
t.Run("panel migration dump", func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := database.InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
dump, err := database.DumpSQLiteToBytes(dbPath)
if err != nil {
t.Fatalf("DumpSQLiteToBytes: %v", err)
}
if got := sniffPgImportKind(dump[:64]); got != pgImportSQLiteDump {
t.Errorf("sniffPgImportKind(real migration dump) = %d, want %d", got, pgImportSQLiteDump)
}
})
}