Files
trihuy-russian/database/db.go
T

321 lines
7.6 KiB
Go
Raw Normal View History

2025-09-20 09:35:50 +02:00
// Package database provides database initialization, migration, and management utilities
// for the 3x-ui panel using GORM with SQLite.
2023-02-09 22:48:06 +03:30
package database
import (
2023-05-05 22:51:39 +04:30
"bytes"
"errors"
2023-05-05 22:51:39 +04:30
"io"
2024-07-14 01:22:02 +02:00
"log"
2023-02-09 22:48:06 +03:30
"os"
"path"
2025-05-03 12:27:53 +03:00
"slices"
"strings"
"time"
2023-07-01 15:56:43 +03:30
2026-05-10 02:13:42 +02:00
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/crypto"
"github.com/mhsanaei/3x-ui/v3/xray"
2023-02-16 19:28:20 +03:30
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
2023-02-09 22:48:06 +03:30
)
var db *gorm.DB
2024-07-14 01:22:02 +02:00
const (
defaultUsername = "admin"
defaultPassword = "admin"
)
func initModels() error {
2025-03-12 20:13:51 +01:00
models := []any{
2024-07-14 01:22:02 +02:00
&model.User{},
&model.Inbound{},
&model.OutboundTraffics{},
2025-02-04 11:27:58 +01:00
&model.Setting{},
2024-07-14 01:22:02 +02:00
&model.InboundClientIps{},
&xray.ClientTraffic{},
2025-05-03 12:27:53 +03:00
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
2026-05-09 17:38:48 +02:00
&model.Node{},
&model.ApiToken{},
2024-07-14 01:22:02 +02:00
}
for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil {
if isIgnorableDuplicateColumnErr(err, mdl) {
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
continue
}
2025-02-04 11:27:58 +01:00
log.Printf("Error auto migrating model: %v", err)
2024-07-14 01:22:02 +02:00
return err
}
}
return nil
2023-05-23 02:43:15 +03:30
}
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
if err == nil {
return false
}
errMsg := strings.ToLower(err.Error())
const dupPrefix = "duplicate column name:"
if !strings.Contains(errMsg, dupPrefix) {
return false
}
idx := strings.Index(errMsg, dupPrefix)
if idx < 0 {
return false
}
col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
col = strings.Trim(col, "`\"[]")
if col == "" {
return false
}
return db != nil && db.Migrator().HasColumn(mdl, col)
}
2025-09-20 09:35:50 +02:00
// initUser creates a default admin user if the users table is empty.
2023-02-09 22:48:06 +03:30
func initUser() error {
2024-07-14 01:22:02 +02:00
empty, err := isTableEmpty("users")
2023-02-09 22:48:06 +03:30
if err != nil {
2024-07-14 01:22:02 +02:00
log.Printf("Error checking if users table is empty: %v", err)
2023-02-09 22:48:06 +03:30
return err
}
2024-07-14 01:22:02 +02:00
if empty {
2025-05-03 12:27:53 +03:00
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
if err != nil {
log.Printf("Error hashing default password: %v", err)
return err
}
2023-02-09 22:48:06 +03:30
user := &model.User{
2025-05-08 21:20:58 +07:00
Username: defaultUsername,
Password: hashedPassword,
2023-02-09 22:48:06 +03:30
}
return db.Create(user).Error
}
return nil
}
2025-09-20 09:35:50 +02:00
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
2025-05-03 12:27:53 +03:00
func runSeeders(isUsersEmpty bool) error {
empty, err := isTableEmpty("history_of_seeders")
if err != nil {
log.Printf("Error checking if users table is empty: %v", err)
return err
}
if empty && isUsersEmpty {
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
if err := db.Create(hashSeeder).Error; err != nil {
return err
}
return seedApiTokens()
}
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
return err
}
2025-05-03 12:27:53 +03:00
for _, user := range users {
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
2025-05-03 12:27:53 +03:00
}
}
2025-05-03 12:27:53 +03:00
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
if err := db.Create(hashSeeder).Error; err != nil {
return err
2025-05-03 12:27:53 +03:00
}
}
if !slices.Contains(seedersHistory, "ApiTokensTable") {
if err := seedApiTokens(); err != nil {
return err
}
}
2025-05-03 12:27:53 +03:00
return nil
}
// seedApiTokens copies the legacy `apiToken` setting into the new
// api_tokens table as a row named "default" so existing central panels
// keep working after the upgrade. Idempotent — records itself in
// history_of_seeders and only runs when api_tokens is empty.
func seedApiTokens() error {
empty, err := isTableEmpty("api_tokens")
if err != nil {
return err
}
if empty {
var legacy model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
if err == nil && legacy.Value != "" {
row := &model.ApiToken{
Name: "default",
Token: legacy.Value,
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
log.Printf("Error migrating legacy apiToken: %v", err)
return err
}
}
}
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
}
2025-09-20 09:35:50 +02:00
// isTableEmpty returns true if the named table contains zero rows.
2024-07-14 01:22:02 +02:00
func isTableEmpty(tableName string) (bool, error) {
var count int64
err := db.Table(tableName).Count(&count).Error
return count == 0, err
2023-02-09 22:48:06 +03:30
}
2025-09-20 09:35:50 +02:00
// InitDB sets up the database connection, migrates models, and runs seeders.
2023-02-09 22:48:06 +03:30
func InitDB(dbPath string) error {
dir := path.Dir(dbPath)
err := os.MkdirAll(dir, 0755)
2023-02-09 22:48:06 +03:30
if err != nil {
return err
}
var gormLogger logger.Interface
if config.IsDebug() {
gormLogger = logger.Default
} else {
gormLogger = logger.Discard
}
c := &gorm.Config{
2025-02-04 11:27:58 +01:00
Logger: gormLogger,
2023-02-09 22:48:06 +03:30
}
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
db, err = gorm.Open(sqlite.Open(dsn), c)
2023-02-09 22:48:06 +03:30
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
return err
}
sqlDB.SetMaxOpenConns(8)
sqlDB.SetMaxIdleConns(4)
sqlDB.SetConnMaxLifetime(time.Hour)
2024-07-14 01:22:02 +02:00
if err := initModels(); err != nil {
return err
}
2025-05-03 12:27:53 +03:00
isUsersEmpty, err := isTableEmpty("users")
2025-09-18 22:06:01 +02:00
if err != nil {
return err
}
2025-05-03 12:27:53 +03:00
2024-07-14 01:22:02 +02:00
if err := initUser(); err != nil {
return err
}
2025-05-03 12:27:53 +03:00
return runSeeders(isUsersEmpty)
2024-07-14 01:22:02 +02:00
}
2025-09-20 09:35:50 +02:00
// CloseDB closes the database connection if it exists.
2024-07-14 01:22:02 +02:00
func CloseDB() error {
if db != nil {
sqlDB, err := db.DB()
if err != nil {
2023-05-23 02:43:15 +03:30
return err
}
2024-07-14 01:22:02 +02:00
return sqlDB.Close()
2023-02-09 22:48:06 +03:30
}
return nil
}
2025-09-20 09:35:50 +02:00
// GetDB returns the global GORM database instance.
2023-02-09 22:48:06 +03:30
func GetDB() *gorm.DB {
return db
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
2023-02-09 22:48:06 +03:30
}
2023-05-05 22:51:39 +04:30
2025-09-20 09:35:50 +02:00
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
2023-05-23 02:43:15 +03:30
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
2023-05-05 22:51:39 +04:30
signature := []byte("SQLite format 3\x00")
buf := make([]byte, len(signature))
2023-05-23 02:43:15 +03:30
_, err := file.ReadAt(buf, 0)
2023-05-05 22:51:39 +04:30
if err != nil {
return false, err
}
return bytes.Equal(buf, signature), nil
}
2023-12-08 20:35:10 +01:00
2025-09-20 09:35:50 +02:00
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
2023-12-08 20:35:10 +01:00
func Checkpoint() error {
// Update WAL
err := db.Exec("PRAGMA wal_checkpoint;").Error
if err != nil {
return err
}
return nil
}
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
// It does not mutate global state or run migrations.
func ValidateSQLiteDB(dbPath string) error {
if _, err := os.Stat(dbPath); err != nil { // file must exist
return err
}
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
return err
}
sqlDB, err := gdb.DB()
if err != nil {
return err
}
defer sqlDB.Close()
var res string
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
return err
}
if res != "ok" {
return errors.New("sqlite integrity check failed: " + res)
}
return nil
}