package cmd import ( "errors" "fmt" "regexp" "strings" "github.com/fatih/color" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/tools/types" "github.com/spf13/cobra" ) // Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes. // // NB! It will be removed in v0.9.0! func NewTempUpgradeCommand(app core.App) *cobra.Command { command := &cobra.Command{ Use: "upgrade", Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes", Long: ` Upgrades your existing pb_data to be compatible with the v0.8.x changes Prerequisites and caveats: - already upgraded to v0.7.* - no existing users collection - existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc. `, Run: func(command *cobra.Command, args []string) { if err := upgrade(app); err != nil { color.Red("Error: %v", err) } }, } return command } func upgrade(app core.App) error { if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil { return errors.New("It seems that you've already upgraded or have an existing 'users' collection.") } return app.Dao().RunInTransaction(func(txDao *daos.Dao) error { if err := migrateCollections(txDao); err != nil { return err } if err := migrateUsers(app, txDao); err != nil { return err } if err := resetMigrationsTable(txDao); err != nil { return err } bold := color.New(color.Bold).Add(color.FgGreen) bold.Println("The pb_data upgrade completed successfully!") bold.Println("You can now start the application as usual with the 'serve' command.") bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.") fmt.Println() return nil }) } // ------------------------------------------------------------------- func migrateCollections(txDao *daos.Dao) error { // add new collection columns if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil { return err } if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil { return err } ruleReplacements := []struct { old string new string }{ {"expand", "expand2"}, {"collecitonId", "collectionId2"}, {"collecitonName", "collectionName2"}, {"profile.userId", "profile.id"}, // @collection.* {"@collection.profiles.userId", "@collection.users.id"}, {"@collection.profiles.username", "@collection.users.username2"}, {"@collection.profiles.email", "@collection.users.email2"}, {"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"}, {"@collection.profiles.verified", "@collection.users.verified2"}, {"@collection.profiles.tokenKey", "@collection.users.tokenKey2"}, {"@collection.profiles.passwordHash", "@collection.users.passwordHash2"}, {"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"}, {"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"}, {"@collection.profiles.", "@collection.users."}, // @request.* {"@request.user.profile.userId", "@request.auth.id"}, {"@request.user.profile.username", "@request.auth.username2"}, {"@request.user.profile.email", "@request.auth.email2"}, {"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"}, {"@request.user.profile.verified", "@request.auth.verified2"}, {"@request.user.profile.tokenKey", "@request.auth.tokenKey2"}, {"@request.user.profile.passwordHash", "@request.auth.passwordHash2"}, {"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"}, {"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"}, {"@request.user.profile.", "@request.auth."}, {"@request.user", "@request.auth"}, } collections := []*models.Collection{} if err := txDao.CollectionQuery().All(&collections); err != nil { return err } for _, collection := range collections { collection.Type = models.CollectionTypeBase collection.NormalizeOptions() // rename profile fields // --- fieldsToRename := []string{ "collectionId", "collectionName", "expand", } if collection.Name == "profiles" { fieldsToRename = append(fieldsToRename, "username", "email", "emailVisibility", "verified", "tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt", ) } for _, name := range fieldsToRename { f := collection.Schema.GetFieldByName(name) if f != nil { color.Blue("[%s - renamed field]", collection.Name) color.Yellow(" - old: %s", f.Name) color.Green(" - new: %s2", f.Name) fmt.Println() f.Name += "2" } } // --- // replace rule fields // --- rules := map[string]*string{ "ListRule": collection.ListRule, "ViewRule": collection.ViewRule, "CreateRule": collection.CreateRule, "UpdateRule": collection.UpdateRule, "DeleteRule": collection.DeleteRule, } for ruleKey, rule := range rules { if rule == nil || *rule == "" { continue } originalRule := *rule for _, replacement := range ruleReplacements { re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`) *rule = re.ReplaceAllString(*rule, replacement.new) } *rule = replaceReversedLikes(*rule) if originalRule != *rule { color.Blue("[%s - replaced %s]:", collection.Name, ruleKey) color.Yellow(" - old: %s", strings.TrimSpace(originalRule)) color.Green(" - new: %s", strings.TrimSpace(*rule)) fmt.Println() } } // --- if err := txDao.SaveCollection(collection); err != nil { return err } } return nil } func migrateUsers(app core.App, txDao *daos.Dao) error { color.Blue(`[merging "_users" and "profiles"]:`) profilesCollection, err := txDao.FindCollectionByNameOrId("profiles") if err != nil { return err } originalProfilesCollectionId := profilesCollection.Id // change the profiles collection id to something else since we will be using // it for the new users collection in order to avoid renaming the storage dir _, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf( `UPDATE {{_collections}} SET id = '%s' WHERE id = '%s'; `, (originalProfilesCollectionId + "__old__"), originalProfilesCollectionId, )).Execute() if idRenameErr != nil { return idRenameErr } // refresh profiles collection profilesCollection, err = txDao.FindCollectionByNameOrId("profiles") if err != nil { return err } usersSchema, _ := profilesCollection.Schema.Clone() userIdField := usersSchema.GetFieldByName("userId") if userIdField != nil { usersSchema.RemoveField(userIdField.Id) } usersCollection := &models.Collection{} usersCollection.MarkAsNew() usersCollection.Id = originalProfilesCollectionId usersCollection.Name = "users" usersCollection.Type = models.CollectionTypeAuth usersCollection.Schema = *usersSchema usersCollection.CreateRule = types.Pointer("") if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" { *profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id") usersCollection.ListRule = profilesCollection.ListRule } if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" { *profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id") usersCollection.ViewRule = profilesCollection.ViewRule } if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" { *profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id") usersCollection.UpdateRule = profilesCollection.UpdateRule } if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" { *profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id") usersCollection.DeleteRule = profilesCollection.DeleteRule } // set auth options settings := app.Settings() authOptions := usersCollection.AuthOptions() authOptions.ManageRule = nil authOptions.AllowOAuth2Auth = true authOptions.AllowUsernameAuth = false authOptions.AllowEmailAuth = settings.EmailAuth.Enabled authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains // twitter currently is the only provider that doesn't return an email authOptions.RequireEmail = !settings.TwitterAuth.Enabled usersCollection.SetOptions(authOptions) if err := txDao.SaveCollection(usersCollection); err != nil { return err } // copy the original users _, usersErr := txDao.DB().NewQuery(` INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt) SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt FROM {{_users}}; `).Execute() if usersErr != nil { return usersErr } // generate the profile fields copy statements sets := []string{"id = p.id"} for _, f := range usersSchema.Fields() { sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name)) } // copy profile fields _, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(` UPDATE {{users}} as u SET %s FROM {{profiles}} as p WHERE u.id = p.userId; `, strings.Join(sets, ", "))).Execute() if copyProfileErr != nil { return copyProfileErr } profileRecords, err := txDao.FindRecordsByExpr("profiles") if err != nil { return err } // update all profiles and users fields to point to the new users collection collections := []*models.Collection{} if err := txDao.CollectionQuery().All(&collections); err != nil { return err } for _, collection := range collections { var hasChanges bool for _, f := range collection.Schema.Fields() { f.InitOptions() if f.Type == schema.FieldTypeUser { if collection.Name == "profiles" && f.Name == "userId" { continue } hasChanges = true // change the user field to a relation field options, _ := f.Options.(*schema.UserOptions) f.Type = schema.FieldTypeRelation f.Options = &schema.RelationOptions{ CollectionId: usersCollection.Id, MaxSelect: &options.MaxSelect, CascadeDelete: options.CascadeDelete, } for _, p := range profileRecords { pId := p.Id pUserId := p.GetString("userId") // replace all user record id references with the profile id _, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(` UPDATE %s SET [[%s]] = REPLACE([[%s]], '%s', '%s') WHERE [[%s]] LIKE ('%%%s%%'); `, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute() if replaceErr != nil { return replaceErr } } } } if hasChanges { if err := txDao.Save(collection); err != nil { return err } } } if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil { return err } // drop _users table if _, err := txDao.DB().DropTable("_users").Execute(); err != nil { return err } // drop profiles table if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil { return err } // delete profiles collection if err := txDao.Delete(profilesCollection); err != nil { return err } color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`) fmt.Println() return nil } func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error { _, alterErr := txDao.DB().NewQuery(` -- crate new externalAuths table CREATE TABLE {{_newExternalAuths}} ( [[id]] TEXT PRIMARY KEY, [[collectionId]] TEXT NOT NULL, [[recordId]] TEXT NOT NULL, [[provider]] TEXT NOT NULL, [[providerId]] TEXT NOT NULL, [[created]] TEXT DEFAULT "" NOT NULL, [[updated]] TEXT DEFAULT "" NOT NULL, --- FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE ); -- copy all data from the old table to the new one INSERT INTO {{_newExternalAuths}} SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated FROM {{_externalAuths}} auth INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]]; -- drop old table DROP TABLE {{_externalAuths}}; -- rename new table ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}}; -- create named indexes CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]); CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]); `).Execute() return alterErr } func resetMigrationsTable(txDao *daos.Dao) error { // reset the migration state to the new init _, err := txDao.DB().Delete("_migrations", dbx.HashExp{ "file": "1661586591_add_externalAuths_table.go", }).Execute() return err } var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`) func replaceReversedLikes(rule string) string { parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1) for _, p := range parts { if len(p) != 4 { continue } newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1]) rule = strings.ReplaceAll(rule, p[0], newPart) } return rule }