diff --git a/db.go b/db.go index beb1131..b18dedc 100644 --- a/db.go +++ b/db.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + mapset "github.com/deckarep/golang-set/v2" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -53,7 +54,7 @@ type V1User struct { Username string `gorm:"unique;not null"` PasswordSalt []byte `gorm:"not null"` PasswordHash []byte `gorm:"not null"` - Clients []V3Client `gorm:"foreignKey:UserUUID"` + Clients []V1Client `gorm:"foreignKey:UserUUID"` ServerID sql.NullString PlayerName string `gorm:"unique;not null;type:text collate nocase"` FallbackPlayer string @@ -71,26 +72,28 @@ func (V1User) TableName() string { return "users" } -type V2Client struct { +type V1Client struct { ClientToken string `gorm:"primaryKey"` Version int UserUUID string User V3User } -func (V2Client) TableName() string { +func (V1Client) TableName() string { return "clients" } -type V3Client struct { +type V2User = V1User + +type V2Client struct { UUID string `gorm:"primaryKey"` ClientToken string Version int UserUUID string - User V3User + User V2User } -func (V3Client) TableName() string { +func (V2Client) TableName() string { return "clients" } @@ -120,6 +123,8 @@ func (V3User) TableName() string { return "users" } +type V3Client = V2Client + type V4User = User type V4Player = Player type V4Client = Client @@ -132,7 +137,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(db, alreadyExisted) + err = Migrate(config, db, alreadyExisted, CURRENT_USER_VERSION) if err != nil { return nil, fmt.Errorf("Error migrating database: %w", err) } @@ -145,7 +150,7 @@ func setUserVersion(tx *gorm.DB, userVersion uint) error { return tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", userVersion)).Error } -func migrate(db *gorm.DB, alreadyExisted bool) error { +func Migrate(config *Config, db *gorm.DB, alreadyExisted bool, targetUserVersion uint) error { var userVersion uint if alreadyExisted { @@ -153,16 +158,18 @@ func migrate(db *gorm.DB, alreadyExisted bool) error { return nil } } else { - userVersion = CURRENT_USER_VERSION + userVersion = targetUserVersion } initialUserVersion := userVersion - if initialUserVersion < CURRENT_USER_VERSION { - log.Printf("Started migration of database version %d to %d", userVersion, CURRENT_USER_VERSION) + 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) } err := db.Transaction(func(tx *gorm.DB) error { - if userVersion == 0 { + if userVersion == 0 && targetUserVersion >= 1 { // Version 0 to 1 // Add User.OfflineUUID if err := tx.AutoMigrate(&V1User{}); err != nil { @@ -183,7 +190,7 @@ func migrate(db *gorm.DB, alreadyExisted bool) error { } userVersion += 1 } - if userVersion == 1 { + if userVersion == 1 && targetUserVersion >= 2 { // Version 1 to 2 // Change Client primaryKey from ClientToken to UUID if err := tx.Exec("ALTER TABLE clients RENAME client_token TO uuid").Error; err != nil { @@ -197,7 +204,7 @@ func migrate(db *gorm.DB, alreadyExisted bool) error { } userVersion += 1 } - if userVersion == 2 { + if userVersion == 2 && targetUserVersion >= 3 { // Version 2 to 3 // Add User.APIToken @@ -219,7 +226,7 @@ func migrate(db *gorm.DB, alreadyExisted bool) error { } userVersion += 1 } - if userVersion == 3 { + if userVersion == 3 && targetUserVersion >= 4 { // Version 3 to 4 // Split Users and Players @@ -231,15 +238,26 @@ func migrate(db *gorm.DB, alreadyExisted bool) error { if err := tx.AutoMigrate(&V4User{}); err != nil { return err } + if err := tx.AutoMigrate(&V4Player{}); err != nil { + return err + } if err := tx.AutoMigrate(&V4Client{}); err != nil { return err } - // Drop player_name, it has a non-null constraint and SQLite has no - // mechanism to remove it + // Drop player_name and offline_uuid, they have non-null + // constraints and SQLite has no mechanism to remove them if err := tx.Migrator().DropColumn(&V4User{}, "player_name"); err != nil { return err } + if err := tx.Migrator().DropColumn(&V4User{}, "offline_uuid"); err != nil { + return err + } + + allUsernames := mapset.NewSet[string]() + for _, v3User := range v3Users { + allUsernames.Add(v3User.Username) + } users := make([]V4User, 0, len(v3Users)) for _, v3User := range v3Users { @@ -253,9 +271,15 @@ func migrate(db *gorm.DB, alreadyExisted bool) error { PlayerUUID: &v3Client.UserUUID, }) } + // If the player name is in use as someone else's username, + // reset the player name to its owner's username + playerName := v3User.PlayerName + if playerName != v3User.Username && allUsernames.Contains(playerName) { + playerName = v3User.Username + } player := V4Player{ UUID: v3User.UUID, - Name: v3User.PlayerName, + Name: playerName, OfflineUUID: v3User.OfflineUUID, CreatedAt: v3User.CreatedAt, NameLastChangedAt: v3User.NameLastChangedAt, @@ -387,7 +411,7 @@ func migrate(db *gorm.DB, alreadyExisted bool) error { return err } - if initialUserVersion < CURRENT_USER_VERSION { + if initialUserVersion < targetUserVersion { log.Printf("Finished migration from version %d to %d", initialUserVersion, userVersion) } diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..aebf001 --- /dev/null +++ b/db_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "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{ + Logger: logger.Default.LogMode(logger.Silent), + }) + assert.Nil(t, err) + return db +} + +func TestDB(t *testing.T) { + t.Parallel() + ts := TestSuite{} + + log.SetOutput(io.Discard) + + tempStateDirectory := Unwrap(os.MkdirTemp("", "tmp")) + ts.StateDirectory = tempStateDirectory + + config := DefaultConfig() + config.StateDirectory = tempStateDirectory + config.DataDirectory = "." + ts.Config = &config + + defer ts.Teardown() + + t.Run("Test with a fresh database", ts.testFreshDatabase) + t.Run("Test 1->2 migration", ts.testMigrate1To2) + 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) +} + +func (ts *TestSuite) testFreshDatabase(t *testing.T) { + db := ts.getFreshDatabase(t) + err := Migrate(ts.Config, db, false, CURRENT_USER_VERSION) + assert.Nil(t, err) +} + +func (ts *TestSuite) testMigrate1To2(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) + + var v1Client V1Client + assert.Nil(t, db.First(&v1Client).Error) + + err = Migrate(ts.Config, db, true, 2) + assert.Nil(t, err) + + var v2Client V2Client + assert.Nil(t, db.First(&v2Client).Error) + assert.NotEqual(t, "", v2Client.UUID) + assert.Equal(t, v1Client.UserUUID, v2Client.UserUUID) + assert.Equal(t, v1Client.Version, v2Client.Version) +} + +func (ts *TestSuite) testMigrate2To3(t *testing.T) { + db := ts.getFreshDatabase(t) + + query, err := os.ReadFile("sql/2.sql") + assert.Nil(t, err) + assert.Nil(t, db.Exec(string(query)).Error) + + var v2User V2User + assert.Nil(t, db.First(&v2User).Error) + + err = Migrate(ts.Config, db, true, 3) + assert.Nil(t, err) + + var v3User V3User + assert.Nil(t, db.First(&v3User).Error) + assert.NotEqual(t, "", v3User.APIToken) +} + +func (ts *TestSuite) testMigrate3To4(t *testing.T) { + db := ts.getFreshDatabase(t) + + query, err := os.ReadFile("sql/3.sql") + assert.Nil(t, err) + assert.Nil(t, db.Exec(string(query)).Error) + + var v3User V3User + assert.Nil(t, db.First(&v3User).Error) + + err = Migrate(ts.Config, db, true, 4) + assert.Nil(t, err) + + var v4User V4User + assert.Nil(t, db.First(&v4User).Error) + assert.Equal(t, 1, len(v4User.Players)) + player := v4User.Players[0] + assert.Equal(t, v3User.OfflineUUID, player.OfflineUUID) + assert.Equal(t, *UnmakeNullString(&v3User.SkinHash), *UnmakeNullString(&player.SkinHash)) + assert.Equal(t, *UnmakeNullString(&v3User.CapeHash), *UnmakeNullString(&player.CapeHash)) +} + +func (ts *TestSuite) testMigrate3To4Collision(t *testing.T) { + // User foo has player qux + // User qux has player foo + // After migration, user foo should have player foo and user qux should + // have player qux + + db := ts.getFreshDatabase(t) + + query, err := os.ReadFile("sql/3-username-player-name-collison.sql") + assert.Nil(t, err) + assert.Nil(t, db.Exec(string(query)).Error) + + var v3foo V3User + assert.Nil(t, db.First(&v3foo, "username = ?", "foo").Error) + assert.Equal(t, "qux", v3foo.PlayerName) + + var v3qux V3User + assert.Nil(t, db.First(&v3qux, "username = ?", "qux").Error) + assert.Equal(t, "foo", v3qux.PlayerName) + + err = Migrate(ts.Config, db, true, 4) + assert.Nil(t, err) + + var v4foo V4User + assert.Nil(t, db.First(&v4foo, "username = ?", "foo").Error) + assert.Equal(t, 1, len(v4foo.Players)) + assert.Equal(t, "foo", v4foo.Players[0].Name) + + var v4qux V4User + assert.Nil(t, db.First(&v4qux, "username = ?", "qux").Error) + assert.Equal(t, 1, len(v4qux.Players)) + assert.Equal(t, "qux", v4qux.Players[0].Name) +} diff --git a/flake.nix b/flake.nix index 8687533..d1512f3 100644 --- a/flake.nix +++ b/flake.nix @@ -48,7 +48,7 @@ ]; # Update whenever Go dependencies change - vendorHash = "sha256-XLkICl7cL6FaWArl99xUH6kYLxHAZ/VsS0sP3d4yLws="; + vendorHash = "sha256-bkmi3yvE/JPQvTjBzOHZ07PBVXp8lQEv2l0q6GTC94k="; outputs = ["out"]; diff --git a/go.mod b/go.mod index a1ab64c..1ccca10 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/glog v1.1.2 // indirect diff --git a/go.sum b/go.sum index d5339ae..a6cd838 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= diff --git a/sql/1.sql b/sql/1.sql new file mode 100644 index 0000000..72e5385 --- /dev/null +++ b/sql/1.sql @@ -0,0 +1,13 @@ +PRAGMA user_version=1; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE `users` (`is_admin` numeric,`is_locked` numeric,`uuid` text,`username` text NOT NULL UNIQUE,`password_salt` blob NOT NULL,`password_hash` blob NOT NULL,`server_id` text,`player_name` text collate nocase NOT NULL UNIQUE,`offline_uuid` text,`fallback_player` text,`preferred_language` text,`browser_token` text,`skin_hash` text,`skin_model` text,`cape_hash` text,`created_at` datetime,`name_last_changed_at` datetime,PRIMARY KEY (`uuid`)); +INSERT INTO users VALUES(1,0,'dc500452-7745-4939-a187-a8ce37beca28','foo',X'cdae655061130b4594991676095739d6',X'76f1040e8fa5c96f6d94b3b088d8079bfbc46efcb94d270a750a5673c88e757d',NULL,'foo','ab980ae0-02d3-3064-adcf-22d6ca24b404','dc500452-7745-4939-a187-a8ce37beca28','en','23313163410b65f0fcb4bc1beea75a3a8bfbbc25af626c3e1d34b61f9f8c050b',NULL,'classic',NULL,'2024-11-27 17:21:50.994337304-05:00','2024-11-27 17:21:50.994337485-05:00'); +CREATE TABLE `clients` (`client_token` text,`version` integer,`user_uuid` text,PRIMARY KEY (`client_token`),CONSTRAINT `fk_users_clients` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`)); +INSERT INTO clients VALUES('e7926dc1e9b74b598251dd16277d0bba',0,'dc500452-7745-4939-a187-a8ce37beca28'); +CREATE TABLE `invites` (`code` text,`created_at` datetime,PRIMARY KEY (`code`)); +INSERT INTO invites VALUES('cwB03PjPqSJ','2024-11-27 17:22:00.218111184-05:00'); +CREATE INDEX `idx_users_cape_hash` ON `users`(`cape_hash`); +CREATE INDEX `idx_users_skin_hash` ON `users`(`skin_hash`); +CREATE INDEX `idx_users_browser_token` ON `users`(`browser_token`); +COMMIT; diff --git a/sql/2.sql b/sql/2.sql new file mode 100644 index 0000000..6be069b --- /dev/null +++ b/sql/2.sql @@ -0,0 +1,12 @@ +PRAGMA user_version=2; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE `users` (`is_admin` numeric,`is_locked` numeric,`uuid` text,`username` text NOT NULL UNIQUE,`password_salt` blob NOT NULL,`password_hash` blob NOT NULL,`server_id` text,`player_name` text collate nocase NOT NULL UNIQUE,`offline_uuid` text,`fallback_player` text,`preferred_language` text,`browser_token` text,`skin_hash` text,`skin_model` text,`cape_hash` text,`created_at` datetime,`name_last_changed_at` datetime,PRIMARY KEY (`uuid`)); +INSERT INTO users VALUES(1,0,'d8783ef3-39f9-4bd9-af49-adfc778bf2b5','foo',X'24e51b055d01172da6bdea76f5708688',X'f969f3d96a28006ddfe376a5a38f5a81749d403a42f58cf2852c2c24477271ea',NULL,'foo','ab980ae0-02d3-3064-adcf-22d6ca24b404','d8783ef3-39f9-4bd9-af49-adfc778bf2b5','en','84ea8e23311b4049e0d7b9dbf1f7470d2e030e50e411096fe8f0968146ad9071',NULL,'classic',NULL,'2024-11-28 11:35:08.923943529-05:00','2024-11-28 11:35:08.92394374-05:00'); +CREATE TABLE `clients` (`uuid` text,`client_token` text,`version` integer,`user_uuid` text,PRIMARY KEY (`uuid`),CONSTRAINT `fk_users_clients` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`)); +INSERT INTO clients VALUES('d76bcd64-0499-4c14-9826-2a40b84439cf','4236ebec07e34e13ab6890e3dac36fbf',0,'d8783ef3-39f9-4bd9-af49-adfc778bf2b5'); +CREATE TABLE `invites` (`code` text,`created_at` datetime,PRIMARY KEY (`code`)); +CREATE INDEX `idx_users_cape_hash` ON `users`(`cape_hash`); +CREATE INDEX `idx_users_skin_hash` ON `users`(`skin_hash`); +CREATE INDEX `idx_users_browser_token` ON `users`(`browser_token`); +COMMIT; diff --git a/sql/3-username-player-name-collison.sql b/sql/3-username-player-name-collison.sql new file mode 100644 index 0000000..6ef20fc --- /dev/null +++ b/sql/3-username-player-name-collison.sql @@ -0,0 +1,13 @@ +PRAGMA user_version=3; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE `users` (`is_admin` numeric,`is_locked` numeric,`uuid` text,`username` text NOT NULL UNIQUE,`password_salt` blob NOT NULL,`password_hash` blob NOT NULL,`server_id` text,`player_name` text collate nocase NOT NULL UNIQUE,`offline_uuid` text NOT NULL,`fallback_player` text,`preferred_language` text,`browser_token` text,`api_token` text,`skin_hash` text,`skin_model` text,`cape_hash` text,`created_at` datetime,`name_last_changed_at` datetime,PRIMARY KEY (`uuid`)); +INSERT INTO users VALUES(1,0,'8a94719d-94b5-49f6-93c1-bff20aeb9d70','foo',X'a5c1419d67c0ae15e9894e1d505e215e',X'7e5b0222eb21362cea20609501dbe7c69bfcdebca05e66341fe5ad85593ea922',NULL,'qux','abc129bc-460a-324b-90cd-de4d47e63076','8a94719d-94b5-49f6-93c1-bff20aeb9d70','en','f4cf867a642912ac28a73dc7e37f94f5bbf6dba9d2175eb8f12185b179c9b1bb','qVefdhlf90THN49ceNLc1T','27818f0eadf68945ad0880c6c63c2baa0f466ac41960b3b6cc00c51e5dd23125','classic','5630e530c3853fde80d99c60eb91ac8d11061d18f0404a189f73503940473187','2024-11-28 11:41:24.273481686-05:00','2024-11-28 11:53:13.174934976-05:00'); +INSERT INTO users VALUES(0,0,'022f6807-95f8-41a4-ae39-9a4f7ebfc2cf','qux',X'b9169adc02b7d197611b6f947ed8819b',X'fa74d79864b19fd49dd619238eae1a46f70178c5ac2591aa9233f353a2ff4270',NULL,'foo','ab980ae0-02d3-3064-adcf-22d6ca24b404','022f6807-95f8-41a4-ae39-9a4f7ebfc2cf','en',NULL,'qDoveQLAQtvtwdeiD815vM',NULL,'classic',NULL,'2024-11-28 11:52:22.307406939-05:00','2024-11-28 11:52:50.699505043-05:00'); +CREATE TABLE `clients` (`uuid` text,`client_token` text,`version` integer,`user_uuid` text,PRIMARY KEY (`uuid`),CONSTRAINT `fk_users_clients` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`)); +INSERT INTO clients VALUES('1e654965-89f5-4ab5-8b21-9c9087652ce4','951b701320a84d34b6d873c68db58de4',1,'8a94719d-94b5-49f6-93c1-bff20aeb9d70'); +CREATE TABLE `invites` (`code` text,`created_at` datetime,PRIMARY KEY (`code`)); +CREATE INDEX `idx_users_cape_hash` ON `users`(`cape_hash`); +CREATE INDEX `idx_users_skin_hash` ON `users`(`skin_hash`); +CREATE INDEX `idx_users_browser_token` ON `users`(`browser_token`); +COMMIT; diff --git a/sql/3.sql b/sql/3.sql new file mode 100644 index 0000000..33ff90a --- /dev/null +++ b/sql/3.sql @@ -0,0 +1,12 @@ +PRAGMA user_version=3; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE `users` (`is_admin` numeric,`is_locked` numeric,`uuid` text,`username` text NOT NULL UNIQUE,`password_salt` blob NOT NULL,`password_hash` blob NOT NULL,`server_id` text,`player_name` text collate nocase NOT NULL UNIQUE,`offline_uuid` text NOT NULL,`fallback_player` text,`preferred_language` text,`browser_token` text,`api_token` text,`skin_hash` text,`skin_model` text,`cape_hash` text,`created_at` datetime,`name_last_changed_at` datetime,PRIMARY KEY (`uuid`)); +INSERT INTO users VALUES(1,0,'8a94719d-94b5-49f6-93c1-bff20aeb9d70','foo',X'a5c1419d67c0ae15e9894e1d505e215e',X'7e5b0222eb21362cea20609501dbe7c69bfcdebca05e66341fe5ad85593ea922',NULL,'foo','ab980ae0-02d3-3064-adcf-22d6ca24b404','8a94719d-94b5-49f6-93c1-bff20aeb9d70','en','8e1da35a9e20f1651404c3315bfebb438028fb6495b1407622d8546749c4998b','qVefdhlf90THN49ceNLc1T','27818f0eadf68945ad0880c6c63c2baa0f466ac41960b3b6cc00c51e5dd23125','classic','5630e530c3853fde80d99c60eb91ac8d11061d18f0404a189f73503940473187','2024-11-28 11:41:24.273481686-05:00','2024-11-28 11:41:24.273481896-05:00'); +CREATE TABLE `clients` (`uuid` text,`client_token` text,`version` integer,`user_uuid` text,PRIMARY KEY (`uuid`),CONSTRAINT `fk_users_clients` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`)); +INSERT INTO clients VALUES('1e654965-89f5-4ab5-8b21-9c9087652ce4','951b701320a84d34b6d873c68db58de4',1,'8a94719d-94b5-49f6-93c1-bff20aeb9d70'); +CREATE TABLE `invites` (`code` text,`created_at` datetime,PRIMARY KEY (`code`)); +CREATE INDEX `idx_users_cape_hash` ON `users`(`cape_hash`); +CREATE INDEX `idx_users_skin_hash` ON `users`(`skin_hash`); +CREATE INDEX `idx_users_browser_token` ON `users`(`browser_token`); +COMMIT;