diff --git a/common.go b/common.go index cfc4aba..4618c32 100644 --- a/common.go +++ b/common.go @@ -30,7 +30,7 @@ type UserError struct { Err error } -func (e *UserError) Error() string { +func (e UserError) Error() string { return e.Err.Error() } diff --git a/config.go b/config.go index f6112c5..4676646 100644 --- a/config.go +++ b/config.go @@ -81,6 +81,7 @@ type Config struct { ListenAddress string LogRequests bool MinPasswordLength int + PreMigrationBackups bool RateLimit rateLimitConfig RegistrationExistingPlayer registrationExistingPlayerConfig RegistrationNewPlayer registrationNewPlayerConfig @@ -128,6 +129,7 @@ func DefaultConfig() Config { LogRequests: true, MinPasswordLength: 8, OfflineSkins: true, + PreMigrationBackups: true, RateLimit: defaultRateLimitConfig, RegistrationExistingPlayer: registrationExistingPlayerConfig{ Allow: false, diff --git a/db.go b/db.go index 5f25a03..4529f07 100644 --- a/db.go +++ b/db.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" mapset "github.com/deckarep/golang-set/v2" + "github.com/samber/mo" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "log" "os" "path" + "path/filepath" "time" ) @@ -47,6 +49,14 @@ func IsErrorPlayerNameTakenByUsername(err error) bool { return err.Error() == PLAYER_NAME_TAKEN_BY_USERNAME_ERROR } +type BackwardsMigrationError struct { + Err error +} + +func (e BackwardsMigrationError) Error() string { + return e.Err.Error() +} + type V1User struct { IsAdmin bool IsLocked bool @@ -137,7 +147,7 @@ func OpenDB(config *Config) (*gorm.DB, error) { db := Unwrap(gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), })) - err = Migrate(config, db, alreadyExisted, CURRENT_USER_VERSION) + err = Migrate(config, mo.Some(dbPath), db, alreadyExisted, CURRENT_USER_VERSION) if err != nil { return nil, fmt.Errorf("Error migrating database: %w", err) } @@ -150,7 +160,7 @@ func setUserVersion(tx *gorm.DB, userVersion uint) error { return tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", userVersion)).Error } -func Migrate(config *Config, db *gorm.DB, alreadyExisted bool, targetUserVersion uint) error { +func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExisted bool, targetUserVersion uint) error { var userVersion uint if alreadyExisted { @@ -162,10 +172,29 @@ func Migrate(config *Config, db *gorm.DB, alreadyExisted bool, targetUserVersion } initialUserVersion := userVersion + if initialUserVersion > targetUserVersion { + return BackwardsMigrationError{ + Err: fmt.Errorf("Database is version %d, migration target version is %d, cannot continue. Are you trying to run an older version of %s with a newer database?", userVersion, targetUserVersion, config.ApplicationName), + } + } + if initialUserVersion < targetUserVersion { - log.Printf("Started migration of database version %d to %d", userVersion, targetUserVersion) - } else if initialUserVersion > targetUserVersion { - return fmt.Errorf("Database is version %d, migration target version is %d, cannot continue. Are you trying to run an older version of %s with a newer database?", userVersion, targetUserVersion, config.ApplicationName) + log.Printf("Started migration of database version %d to %d.", userVersion, targetUserVersion) + if !config.PreMigrationBackups { + log.Printf("PreMigrationBackups disabled, skipping backup.") + } else if p, ok := dbPath.Get(); ok { + dbDir := filepath.Dir(p) + datetime := time.Now().UTC().Format("2006-01-02T15-04-05Z") + backupPath := path.Join(dbDir, fmt.Sprintf("drasl.%d.%s.db", userVersion, datetime)) + log.Printf("Backing up old database to %s", backupPath) + _, err := CopyPath(p, backupPath) + if err != nil { + return fmt.Errorf("Error backing up database: %w", err) + } + log.Printf("Database backed up, proceeding.") + } else { + log.Printf("Database path not specified, skipping backup.") + } } err := db.Transaction(func(tx *gorm.DB) error { diff --git a/db_test.go b/db_test.go index aebf001..560c7d6 100644 --- a/db_test.go +++ b/db_test.go @@ -1,6 +1,8 @@ package main import ( + "errors" + "github.com/samber/mo" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -8,16 +10,11 @@ import ( "io" "log" "os" - "path" "testing" ) func (ts *TestSuite) getFreshDatabase(t *testing.T) *gorm.DB { - dbPath := path.Join(ts.Config.StateDirectory, "drasl.db") - if err := os.Remove(dbPath); err != nil { - assert.True(t, os.IsNotExist(err)) - } - db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) assert.Nil(t, err) @@ -45,11 +42,12 @@ func TestDB(t *testing.T) { t.Run("Test 2->3 migration", ts.testMigrate2To3) t.Run("Test 3->4 migration", ts.testMigrate3To4) t.Run("Test 3->4 migration, username/player name collision", ts.testMigrate3To4Collision) + t.Run("Test backwards migration", ts.testMigrateBackwards) } func (ts *TestSuite) testFreshDatabase(t *testing.T) { db := ts.getFreshDatabase(t) - err := Migrate(ts.Config, db, false, CURRENT_USER_VERSION) + err := Migrate(ts.Config, mo.None[string](), db, false, CURRENT_USER_VERSION) assert.Nil(t, err) } @@ -63,7 +61,7 @@ func (ts *TestSuite) testMigrate1To2(t *testing.T) { var v1Client V1Client assert.Nil(t, db.First(&v1Client).Error) - err = Migrate(ts.Config, db, true, 2) + err = Migrate(ts.Config, mo.None[string](), db, true, 2) assert.Nil(t, err) var v2Client V2Client @@ -83,7 +81,7 @@ func (ts *TestSuite) testMigrate2To3(t *testing.T) { var v2User V2User assert.Nil(t, db.First(&v2User).Error) - err = Migrate(ts.Config, db, true, 3) + err = Migrate(ts.Config, mo.None[string](), db, true, 3) assert.Nil(t, err) var v3User V3User @@ -101,7 +99,7 @@ func (ts *TestSuite) testMigrate3To4(t *testing.T) { var v3User V3User assert.Nil(t, db.First(&v3User).Error) - err = Migrate(ts.Config, db, true, 4) + err = Migrate(ts.Config, mo.None[string](), db, true, 4) assert.Nil(t, err) var v4User V4User @@ -133,7 +131,7 @@ func (ts *TestSuite) testMigrate3To4Collision(t *testing.T) { assert.Nil(t, db.First(&v3qux, "username = ?", "qux").Error) assert.Equal(t, "foo", v3qux.PlayerName) - err = Migrate(ts.Config, db, true, 4) + err = Migrate(ts.Config, mo.None[string](), db, true, 4) assert.Nil(t, err) var v4foo V4User @@ -146,3 +144,16 @@ func (ts *TestSuite) testMigrate3To4Collision(t *testing.T) { assert.Equal(t, 1, len(v4qux.Players)) assert.Equal(t, "qux", v4qux.Players[0].Name) } + +func (ts *TestSuite) testMigrateBackwards(t *testing.T) { + db := ts.getFreshDatabase(t) + + query, err := os.ReadFile("sql/1.sql") + assert.Nil(t, err) + assert.Nil(t, db.Exec(string(query)).Error) + setUserVersion(db, CURRENT_USER_VERSION+1) + + err = Migrate(ts.Config, mo.None[string](), db, true, CURRENT_USER_VERSION) + var backwardsMigrationError BackwardsMigrationError + assert.True(t, errors.As(err, &backwardsMigrationError)) +} diff --git a/doc/configuration.md b/doc/configuration.md index 2785efc..ff2e3a9 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -20,6 +20,7 @@ Other available options: - `ListenAddress`: IP address and port to listen on. Depending on how you configure your reverse proxy and whether you run Drasl in a container, you should consider setting the listen address to `"127.0.0.1:25585"` to ensure Drasl is only accessible through the reverse proxy. If your reverse proxy is unable to connect to Drasl, try setting this back to the default value. String. Default value: `"0.0.0.0:25585"`. - `DefaultAdmins`: Usernames of the instance's permanent admins. Admin rights can be granted to other accounts using the web UI, but admins defined via `DefaultAdmins` cannot be demoted unless they are removed from the config file. Array of strings. Default value: `[]`. - `DefaultMaxPlayerCount`: Number of players each user is allowed to own by default. Admins can increase or decrease each user's individual limit. Use `-1` to allow creating an unlimited number of players. Integer. Default value: `1`. +- `PreMigrationBackups`: Back up the database to `/path/to/StateDirectory/drasl.X.YYYY-mm-ddTHH-MM-SSZ.db` (where `X` is the old database version) before migrating to a new database version. Boolean. Default value: `true`. - `EnableBackgroundEffect`: Whether to enable the 3D background animation in the web UI. Boolean. Default value: `true`. - `EnableFooter`: Whether to enable the page footer in the web UI. Boolean. Default value: `true`. - `[RateLimit]`: Rate-limit requests per IP address to limit abuse. Only applies to certain web UI routes, not any Yggdrasil routes. Requests for skins, capes, and web pages are also unaffected. Uses [Echo](https://echo.labstack.com)'s [rate limiter middleware](https://echo.labstack.com/middleware/rate-limiter/). diff --git a/util.go b/util.go index 1fa5194..507a1e9 100644 --- a/util.go +++ b/util.go @@ -8,7 +8,9 @@ import ( "crypto/sha256" "encoding/hex" "github.com/jxskiss/base62" + "io" "log" + "os" "strings" "sync" ) @@ -141,3 +143,23 @@ func (m *KeyedMutex) Lock(key string) func() { return func() { mtx.Unlock() } } + +func CopyPath(sourcePath string, destinationPath string) (int64, error) { + source, err := os.Open(sourcePath) + if err != nil { + return 0, err + } + defer source.Close() + + destination, err := os.Create(destinationPath) + if err != nil { + return 0, err + } + defer destination.Close() + + bytesWritten, err := io.Copy(destination, source) + if err != nil { + return 0, err + } + return bytesWritten, nil +}