add new functionality and improve backend server structure

- Enhance server functionality and optimize backend code
- Add multi-database support (PostgreSQL, MySQL, SQLite)
This commit is contained in:
Tola Leng
2026-03-09 17:36:42 +07:00
parent 4b27b85038
commit f5a985ff1e
526 changed files with 173928 additions and 0 deletions
@@ -0,0 +1,94 @@
package migrations
import (
"fmt"
"strings"
"github.com/pocketbase/pocketbase/core"
)
func init() {
core.SystemMigrations.Add(&core.Migration{
Up: func(txApp core.App) error {
if err := createSQLiteEquivalentFunctions(txApp.AuxDB()); err != nil {
return err
}
driver := getDriverName(txApp.AuxDB())
var logsSql string
if driver == "mysql" {
fmt.Println("DEBUG: Running MySQL aux init migration for _logs table")
_, err := txApp.AuxDB().NewQuery(`
CREATE TABLE IF NOT EXISTS {{_logs}} (
[[id]] VARCHAR(100) PRIMARY KEY NOT NULL,
[[level]] INT DEFAULT 0 NOT NULL,
[[message]] TEXT NOT NULL,
[[data]] JSON NOT NULL,
[[created]] VARCHAR(50) NOT NULL
);`).Execute()
if err != nil {
return err
}
// Create indices separately
fmt.Println("DEBUG: Creating index idx_logs_level")
_, err = txApp.AuxDB().NewQuery("CREATE INDEX idx_logs_level on {{_logs}} ([[level]]);").Execute()
if err != nil {
if strings.Contains(err.Error(), "Duplicate key name") {
fmt.Println("DEBUG: Index idx_logs_level already exists, ignoring error")
} else {
return err
}
}
fmt.Println("DEBUG: Creating index idx_logs_message")
_, err = txApp.AuxDB().NewQuery("CREATE INDEX idx_logs_message on {{_logs}} (message(255));").Execute()
if err != nil {
if strings.Contains(err.Error(), "Duplicate key name") {
fmt.Println("DEBUG: Index idx_logs_message already exists, ignoring error")
} else {
return err
}
}
fmt.Println("DEBUG: Creating index idx_logs_created_hour")
_, err = txApp.AuxDB().NewQuery("CREATE INDEX idx_logs_created_hour on {{_logs}} ((DATE_FORMAT([[created]], '%Y-%m-%d %H:00:00')));").Execute()
return err
} else if driver == "postgres" {
logsSql = `
CREATE TABLE IF NOT EXISTS {{_logs}} (
[[id]] UUID PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL,
[[level]] INTEGER DEFAULT 0 NOT NULL,
[[message]] TEXT DEFAULT '' NOT NULL,
[[data]] JSONB DEFAULT '{}' NOT NULL,
[[created]] TIMESTAMP DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logs_level on {{_logs}} ([[level]]);
CREATE INDEX IF NOT EXISTS idx_logs_message on {{_logs}} ([[message]]);
CREATE INDEX IF NOT EXISTS idx_logs_created_hour on {{_logs}} (date_trunc('hour', [[created]]));`
} else {
logsSql = `
CREATE TABLE IF NOT EXISTS {{_logs}} (
[[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
[[level]] INTEGER DEFAULT 0 NOT NULL,
[[message]] TEXT DEFAULT "" NOT NULL,
[[data]] JSON DEFAULT "{}" NOT NULL,
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logs_level on {{_logs}} ([[level]]);
CREATE INDEX IF NOT EXISTS idx_logs_message on {{_logs}} ([[message]]);
CREATE INDEX IF NOT EXISTS idx_logs_created_hour on {{_logs}} (strftime('%Y-%m-%d %H:00:00', [[created]]));`
}
_, execErr := txApp.AuxDB().NewQuery(logsSql).Execute()
return execErr
},
Down: func(txApp core.App) error {
_, err := txApp.AuxDB().DropTable("_logs").Execute()
return err
},
ReapplyCondition: func(txApp core.App, runner *core.MigrationsRunner, fileName string) (bool, error) {
// reapply only if the _logs table doesn't exist
exists := txApp.AuxHasTable("_logs")
return !exists, nil
},
})
}
@@ -0,0 +1,432 @@
package migrations
import (
"fmt"
"path/filepath"
"runtime"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
// Register is a short alias for `AppMigrations.Register()`
// that is usually used in external/user defined migrations.
func Register(
up func(app core.App) error,
down func(app core.App) error,
optFilename ...string,
) {
var optFiles []string
if len(optFilename) > 0 {
optFiles = optFilename
} else {
_, path, _, _ := runtime.Caller(1)
optFiles = append(optFiles, filepath.Base(path))
}
core.AppMigrations.Register(up, down, optFiles...)
}
func init() {
core.SystemMigrations.Register(func(txApp core.App) error {
if err := createSQLiteEquivalentFunctions(txApp.DB()); err != nil {
return fmt.Errorf("createSQLiteEquivalentFunctions error: %w", err)
}
if err := createParamsTable(txApp); err != nil {
return fmt.Errorf("_params exec error: %w", err)
}
// -----------------------------------------------------------
driver := getDriverName(txApp.DB())
var collectionsSql string
if driver == "postgres" {
collectionsSql = `
CREATE TABLE {{_collections}} (
[[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
[[system]] BOOLEAN DEFAULT FALSE NOT NULL,
[[type]] TEXT DEFAULT 'base' NOT NULL,
[[name]] TEXT UNIQUE NOT NULL,
[[fields]] JSONB DEFAULT '[]' NOT NULL,
[[indexes]] JSONB DEFAULT '[]' NOT NULL,
[[listRule]] TEXT DEFAULT NULL,
[[viewRule]] TEXT DEFAULT NULL,
[[createRule]] TEXT DEFAULT NULL,
[[updateRule]] TEXT DEFAULT NULL,
[[deleteRule]] TEXT DEFAULT NULL,
[[options]] JSONB DEFAULT '{}' NOT NULL,
[[created]] TIMESTAMP DEFAULT now() NOT NULL,
[[updated]] TIMESTAMP DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx__collections_type on {{_collections}} ([[type]]);`
} else if driver == "mysql" {
collectionsSql = `
CREATE TABLE {{_collections}} (
[[id]] VARCHAR(15) PRIMARY KEY NOT NULL,
[[system]] BOOLEAN DEFAULT FALSE NOT NULL,
[[type]] VARCHAR(30) DEFAULT 'base' NOT NULL,
[[name]] VARCHAR(255) UNIQUE NOT NULL,
[[fields]] JSON NOT NULL,
[[indexes]] JSON NOT NULL,
[[listRule]] TEXT,
[[viewRule]] TEXT,
[[createRule]] TEXT,
[[updateRule]] TEXT,
[[deleteRule]] TEXT,
[[options]] JSON NOT NULL,
[[created]] VARCHAR(50) NOT NULL,
[[updated]] VARCHAR(50) NOT NULL,
CONSTRAINT idx__collections_name UNIQUE ([[name]])
);`
// Execute table creation
_, err := txApp.DB().NewQuery(collectionsSql).Execute()
if err != nil {
return fmt.Errorf("_collections create error: %w", err)
}
// Execute index creation
_, err = txApp.DB().NewQuery("CREATE INDEX idx__collections_type on {{_collections}} ([[type]]);").Execute()
if err != nil {
return fmt.Errorf("_collections index error: %w", err)
}
// Set collectionsSql to empty or nil to skip the execute below?
// Refactor: move execution inside blocks or skip if handled.
collectionsSql = ""
} else {
collectionsSql = `
CREATE TABLE {{_collections}} (
[[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
[[system]] BOOLEAN DEFAULT FALSE NOT NULL,
[[type]] TEXT DEFAULT 'base' NOT NULL,
[[name]] TEXT UNIQUE NOT NULL,
[[fields]] JSON DEFAULT '[]' NOT NULL,
[[indexes]] JSON DEFAULT '[]' NOT NULL,
[[listRule]] TEXT DEFAULT NULL,
[[viewRule]] TEXT DEFAULT NULL,
[[createRule]] TEXT DEFAULT NULL,
[[updateRule]] TEXT DEFAULT NULL,
[[deleteRule]] TEXT DEFAULT NULL,
[[options]] JSON DEFAULT '{}' NOT NULL,
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx__collections_type on {{_collections}} ([[type]]);`
}
if collectionsSql != "" {
_, execerr := txApp.DB().NewQuery(collectionsSql).Execute()
if execerr != nil {
return fmt.Errorf("_collections exec error: %w", execerr)
}
}
if err := createMFAsCollection(txApp); err != nil {
return fmt.Errorf("_mfas error: %w", err)
}
if err := createOTPsCollection(txApp); err != nil {
return fmt.Errorf("_otps error: %w", err)
}
if err := createExternalAuthsCollection(txApp); err != nil {
return fmt.Errorf("_externalAuths error: %w", err)
}
if err := createAuthOriginsCollection(txApp); err != nil {
return fmt.Errorf("_authOrigins error: %w", err)
}
if err := createSuperusersCollection(txApp); err != nil {
return fmt.Errorf("_superusers error: %w", err)
}
if err := createUsersCollection(txApp); err != nil {
return fmt.Errorf("users error: %w", err)
}
return nil
}, func(txApp core.App) error {
tables := []string{
"users",
core.CollectionNameSuperusers,
core.CollectionNameMFAs,
core.CollectionNameOTPs,
core.CollectionNameAuthOrigins,
"_params",
"_collections",
}
for _, name := range tables {
if _, err := txApp.DB().DropTable(name).Execute(); err != nil {
return err
}
}
return nil
})
}
func createParamsTable(txApp core.App) error {
driver := getDriverName(txApp.DB())
var sql string
if driver == "postgres" {
sql = `
CREATE TABLE {{_params}} (
[[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
[[value]] TEXT DEFAULT NULL, -- Use TEXT because we need to cover encrypted values, which is not a valid JSON.
[[created]] TIMESTAMP DEFAULT NOW() NOT NULL,
[[updated]] TIMESTAMP DEFAULT NOW() NOT NULL
);`
} else if driver == "mysql" {
sql = `
CREATE TABLE {{_params}} (
[[id]] VARCHAR(15) PRIMARY KEY NOT NULL,
[[value]] TEXT,
[[created]] VARCHAR(50) NOT NULL,
[[updated]] VARCHAR(50) NOT NULL
);`
} else {
sql = `
CREATE TABLE {{_params}} (
[[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
[[value]] JSON DEFAULT NULL,
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);`
}
_, execErr := txApp.DB().NewQuery(sql).Execute()
return execErr
}
func createMFAsCollection(txApp core.App) error {
col := core.NewBaseCollection(core.CollectionNameMFAs)
col.System = true
ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
col.ListRule = types.Pointer(ownerRule)
col.ViewRule = types.Pointer(ownerRule)
col.Fields.Add(&core.TextField{
Name: "collectionRef",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "recordRef",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "method",
System: true,
Required: true,
})
col.Fields.Add(&core.AutodateField{
Name: "created",
System: true,
OnCreate: true,
})
col.Fields.Add(&core.AutodateField{
Name: "updated",
System: true,
OnCreate: true,
OnUpdate: true,
})
col.AddIndex("idx_mfas_collectionRef_recordRef", false, "collectionRef,recordRef", "")
return txApp.Save(col)
}
func createOTPsCollection(txApp core.App) error {
col := core.NewBaseCollection(core.CollectionNameOTPs)
col.System = true
ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
col.ListRule = types.Pointer(ownerRule)
col.ViewRule = types.Pointer(ownerRule)
col.Fields.Add(&core.TextField{
Name: "collectionRef",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "recordRef",
System: true,
Required: true,
})
col.Fields.Add(&core.PasswordField{
Name: "password",
System: true,
Hidden: true,
Required: true,
Cost: 8, // low cost for better performance and because it is not critical
})
col.Fields.Add(&core.TextField{
Name: "sentTo",
System: true,
Hidden: true,
})
col.Fields.Add(&core.AutodateField{
Name: "created",
System: true,
OnCreate: true,
})
col.Fields.Add(&core.AutodateField{
Name: "updated",
System: true,
OnCreate: true,
OnUpdate: true,
})
col.AddIndex("idx_otps_collectionRef_recordRef", false, "collectionRef, recordRef", "")
return txApp.Save(col)
}
func createAuthOriginsCollection(txApp core.App) error {
col := core.NewBaseCollection(core.CollectionNameAuthOrigins)
col.System = true
ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
col.ListRule = types.Pointer(ownerRule)
col.ViewRule = types.Pointer(ownerRule)
col.DeleteRule = types.Pointer(ownerRule)
col.Fields.Add(&core.TextField{
Name: "collectionRef",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "recordRef",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "fingerprint",
System: true,
Required: true,
})
col.Fields.Add(&core.AutodateField{
Name: "created",
System: true,
OnCreate: true,
})
col.Fields.Add(&core.AutodateField{
Name: "updated",
System: true,
OnCreate: true,
OnUpdate: true,
})
col.AddIndex("idx_authOrigins_unique_pairs", true, "collectionRef, recordRef, fingerprint", "")
return txApp.Save(col)
}
func createExternalAuthsCollection(txApp core.App) error {
col := core.NewBaseCollection(core.CollectionNameExternalAuths)
col.System = true
ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
col.ListRule = types.Pointer(ownerRule)
col.ViewRule = types.Pointer(ownerRule)
col.DeleteRule = types.Pointer(ownerRule)
col.Fields.Add(&core.TextField{
Name: "collectionRef",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "recordRef",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "provider",
System: true,
Required: true,
})
col.Fields.Add(&core.TextField{
Name: "providerId",
System: true,
Required: true,
})
col.Fields.Add(&core.AutodateField{
Name: "created",
System: true,
OnCreate: true,
})
col.Fields.Add(&core.AutodateField{
Name: "updated",
System: true,
OnCreate: true,
OnUpdate: true,
})
col.AddIndex("idx_externalAuths_record_provider", true, "collectionRef, recordRef, provider", "")
col.AddIndex("idx_externalAuths_collection_provider", true, "collectionRef, provider, providerId", "")
return txApp.Save(col)
}
func createSuperusersCollection(txApp core.App) error {
superusers := core.NewAuthCollection(core.CollectionNameSuperusers)
superusers.System = true
superusers.Fields.Add(&core.EmailField{
Name: "email",
System: true,
Required: true,
})
superusers.Fields.Add(&core.AutodateField{
Name: "created",
System: true,
OnCreate: true,
})
superusers.Fields.Add(&core.AutodateField{
Name: "updated",
System: true,
OnCreate: true,
OnUpdate: true,
})
superusers.AuthToken.Duration = 86400 // 1 day
return txApp.Save(superusers)
}
func createUsersCollection(txApp core.App) error {
users := core.NewAuthCollection("users", "_pb_users_auth_")
ownerRule := "id = @request.auth.id"
users.ListRule = types.Pointer(ownerRule)
users.ViewRule = types.Pointer(ownerRule)
users.CreateRule = types.Pointer("")
users.UpdateRule = types.Pointer(ownerRule)
users.DeleteRule = types.Pointer(ownerRule)
users.Fields.Add(&core.TextField{
Name: "name",
Max: 255,
})
users.Fields.Add(&core.FileField{
Name: "avatar",
MaxSelect: 1,
MimeTypes: []string{"image/jpeg", "image/png", "image/svg+xml", "image/gif", "image/webp"},
})
users.Fields.Add(&core.AutodateField{
Name: "created",
OnCreate: true,
})
users.Fields.Add(&core.AutodateField{
Name: "updated",
OnCreate: true,
OnUpdate: true,
})
users.OAuth2.MappedFields.Name = "name"
users.OAuth2.MappedFields.AvatarURL = "avatar"
return txApp.Save(users)
}
@@ -0,0 +1,912 @@
package migrations
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/crypto/bcrypt"
)
// note: this migration will be deleted in future version
func init_disabled3() {
core.SystemMigrations.Register(func(txApp core.App) error {
// note: mfas and authOrigins tables are available only with v0.23
hasUpgraded := txApp.HasTable(core.CollectionNameMFAs) && txApp.HasTable(core.CollectionNameAuthOrigins)
if hasUpgraded {
return nil
}
oldSettings, err := loadOldSettings(txApp)
if err != nil {
return fmt.Errorf("failed to fetch old settings: %w", err)
}
if err = migrateOldCollections(txApp, oldSettings); err != nil {
return err
}
if err = migrateSuperusers(txApp, oldSettings); err != nil {
return fmt.Errorf("failed to migrate admins->superusers: %w", err)
}
if err = migrateSettings(txApp, oldSettings); err != nil {
return fmt.Errorf("failed to migrate settings: %w", err)
}
if err = migrateExternalAuths(txApp); err != nil {
return fmt.Errorf("failed to migrate externalAuths: %w", err)
}
if err = createMFAsCollection(txApp); err != nil {
return fmt.Errorf("failed to create mfas collection: %w", err)
}
if err = createOTPsCollection(txApp); err != nil {
return fmt.Errorf("failed to create otps collection: %w", err)
}
if err = createAuthOriginsCollection(txApp); err != nil {
return fmt.Errorf("failed to create authOrigins collection: %w", err)
}
err = os.Remove(filepath.Join(txApp.DataDir(), "logs.db"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
txApp.Logger().Warn("Failed to delete old logs.db file", "error", err)
}
return nil
}, nil)
}
// -------------------------------------------------------------------
func migrateSuperusers(txApp core.App, oldSettings *oldSettingsModel) error {
// create new superusers collection and table
err := createSuperusersCollection(txApp)
if err != nil {
return err
}
// update with the token options from the old settings
superusersCollection, err := txApp.FindCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return err
}
superusersCollection.AuthToken.Secret = zeroFallback(
cast.ToString(getMapVal(oldSettings.Value, "adminAuthToken", "secret")),
superusersCollection.AuthToken.Secret,
)
superusersCollection.AuthToken.Duration = zeroFallback(
cast.ToInt64(getMapVal(oldSettings.Value, "adminAuthToken", "duration")),
superusersCollection.AuthToken.Duration,
)
superusersCollection.PasswordResetToken.Secret = zeroFallback(
cast.ToString(getMapVal(oldSettings.Value, "adminPasswordResetToken", "secret")),
superusersCollection.PasswordResetToken.Secret,
)
superusersCollection.PasswordResetToken.Duration = zeroFallback(
cast.ToInt64(getMapVal(oldSettings.Value, "adminPasswordResetToken", "duration")),
superusersCollection.PasswordResetToken.Duration,
)
superusersCollection.FileToken.Secret = zeroFallback(
cast.ToString(getMapVal(oldSettings.Value, "adminFileToken", "secret")),
superusersCollection.FileToken.Secret,
)
superusersCollection.FileToken.Duration = zeroFallback(
cast.ToInt64(getMapVal(oldSettings.Value, "adminFileToken", "duration")),
superusersCollection.FileToken.Duration,
)
if err = txApp.Save(superusersCollection); err != nil {
return fmt.Errorf("failed to migrate token configs: %w", err)
}
// copy old admins records into the new one
_, err = txApp.DB().NewQuery(`
INSERT INTO {{` + core.CollectionNameSuperusers + `}} ([[id]], [[verified]], [[email]], [[password]], [[tokenKey]], [[created]], [[updated]])
SELECT [[id]], true, [[email]], [[passwordHash]], [[tokenKey]], [[created]], [[updated]] FROM {{_admins}};
`).Execute()
if err != nil {
return err
}
// remove old admins table
_, err = txApp.DB().DropTable("_admins").Execute()
if err != nil {
return err
}
return nil
}
// -------------------------------------------------------------------
type oldSettingsModel struct {
Id string `db:"id" json:"id"`
Key string `db:"key" json:"key"`
RawValue types.JSONRaw `db:"value" json:"value"`
Value map[string]any `db:"-" json:"-"`
}
func loadOldSettings(txApp core.App) (*oldSettingsModel, error) {
oldSettings := &oldSettingsModel{Value: map[string]any{}}
err := txApp.DB().Select().From("_params").Where(dbx.HashExp{"key": "settings"}).One(oldSettings)
if err != nil {
return nil, err
}
// try without decrypt
plainDecodeErr := json.Unmarshal(oldSettings.RawValue, &oldSettings.Value)
// failed, try to decrypt
if plainDecodeErr != nil {
encryptionKey := os.Getenv(txApp.EncryptionEnv())
// load without decryption has failed and there is no encryption key to use for decrypt
if encryptionKey == "" {
return nil, fmt.Errorf("invalid settings db data or missing encryption key %q", txApp.EncryptionEnv())
}
// decrypt
decrypted, decryptErr := security.Decrypt(string(oldSettings.RawValue), encryptionKey)
if decryptErr != nil {
return nil, decryptErr
}
// decode again
decryptedDecodeErr := json.Unmarshal(decrypted, &oldSettings.Value)
if decryptedDecodeErr != nil {
return nil, decryptedDecodeErr
}
}
return oldSettings, nil
}
func migrateSettings(txApp core.App, oldSettings *oldSettingsModel) error {
// renamed old params collection
_, err := txApp.DB().RenameTable("_params", "_params_old").Execute()
if err != nil {
return err
}
// create new params table
err = createParamsTable(txApp)
if err != nil {
return err
}
// migrate old settings
newSettings := txApp.Settings()
// ---
newSettings.Meta.AppName = cast.ToString(getMapVal(oldSettings.Value, "meta", "appName"))
newSettings.Meta.AppURL = strings.TrimSuffix(cast.ToString(getMapVal(oldSettings.Value, "meta", "appUrl")), "/")
newSettings.Meta.HideControls = cast.ToBool(getMapVal(oldSettings.Value, "meta", "hideControls"))
newSettings.Meta.SenderName = cast.ToString(getMapVal(oldSettings.Value, "meta", "senderName"))
newSettings.Meta.SenderAddress = cast.ToString(getMapVal(oldSettings.Value, "meta", "senderAddress"))
// ---
newSettings.Logs.MaxDays = cast.ToInt(getMapVal(oldSettings.Value, "logs", "maxDays"))
newSettings.Logs.MinLevel = cast.ToInt(getMapVal(oldSettings.Value, "logs", "minLevel"))
newSettings.Logs.LogIP = cast.ToBool(getMapVal(oldSettings.Value, "logs", "logIp"))
// ---
newSettings.SMTP.Enabled = cast.ToBool(getMapVal(oldSettings.Value, "smtp", "enabled"))
newSettings.SMTP.Port = cast.ToInt(getMapVal(oldSettings.Value, "smtp", "port"))
newSettings.SMTP.Host = cast.ToString(getMapVal(oldSettings.Value, "smtp", "host"))
newSettings.SMTP.Username = cast.ToString(getMapVal(oldSettings.Value, "smtp", "username"))
newSettings.SMTP.Password = cast.ToString(getMapVal(oldSettings.Value, "smtp", "password"))
newSettings.SMTP.AuthMethod = cast.ToString(getMapVal(oldSettings.Value, "smtp", "authMethod"))
newSettings.SMTP.TLS = cast.ToBool(getMapVal(oldSettings.Value, "smtp", "tls"))
newSettings.SMTP.LocalName = cast.ToString(getMapVal(oldSettings.Value, "smtp", "localName"))
// ---
newSettings.Backups.Cron = cast.ToString(getMapVal(oldSettings.Value, "backups", "cron"))
newSettings.Backups.CronMaxKeep = cast.ToInt(getMapVal(oldSettings.Value, "backups", "cronMaxKeep"))
newSettings.Backups.S3 = core.S3Config{
Enabled: cast.ToBool(getMapVal(oldSettings.Value, "backups", "s3", "enabled")),
Bucket: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "bucket")),
Region: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "region")),
Endpoint: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "endpoint")),
AccessKey: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "accessKey")),
Secret: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "secret")),
ForcePathStyle: cast.ToBool(getMapVal(oldSettings.Value, "backups", "s3", "forcePathStyle")),
}
// ---
newSettings.S3 = core.S3Config{
Enabled: cast.ToBool(getMapVal(oldSettings.Value, "s3", "enabled")),
Bucket: cast.ToString(getMapVal(oldSettings.Value, "s3", "bucket")),
Region: cast.ToString(getMapVal(oldSettings.Value, "s3", "region")),
Endpoint: cast.ToString(getMapVal(oldSettings.Value, "s3", "endpoint")),
AccessKey: cast.ToString(getMapVal(oldSettings.Value, "s3", "accessKey")),
Secret: cast.ToString(getMapVal(oldSettings.Value, "s3", "secret")),
ForcePathStyle: cast.ToBool(getMapVal(oldSettings.Value, "s3", "forcePathStyle")),
}
// ---
err = txApp.Save(newSettings)
if err != nil {
return err
}
// remove old params table
_, err = txApp.DB().DropTable("_params_old").Execute()
if err != nil {
return err
}
return nil
}
// -------------------------------------------------------------------
func migrateExternalAuths(txApp core.App) error {
// renamed old externalAuths table
_, err := txApp.DB().RenameTable("_externalAuths", "_externalAuths_old").Execute()
if err != nil {
return err
}
// create new externalAuths collection and table
err = createExternalAuthsCollection(txApp)
if err != nil {
return err
}
// copy old externalAuths records into the new one
_, err = txApp.DB().NewQuery(`
INSERT INTO {{` + core.CollectionNameExternalAuths + `}} ([[id]], [[collectionRef]], [[recordRef]], [[provider]], [[providerId]], [[created]], [[updated]])
SELECT [[id]], [[collectionId]], [[recordId]], [[provider]], [[providerId]], [[created]], [[updated]] FROM {{_externalAuths_old}};
`).Execute()
if err != nil {
return err
}
// remove old externalAuths table
_, err = txApp.DB().DropTable("_externalAuths_old").Execute()
if err != nil {
return err
}
return nil
}
// -------------------------------------------------------------------
func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error {
oldCollections := []*OldCollectionModel{}
err := txApp.DB().Select().From("_collections").All(&oldCollections)
if err != nil {
return err
}
for _, c := range oldCollections {
dummyAuthCollection := core.NewAuthCollection("test")
options := c.Options
c.Options = types.JSONMap[any]{} // reset
// update rules
// ---
c.ListRule = migrateRule(c.ListRule)
c.ViewRule = migrateRule(c.ViewRule)
c.CreateRule = migrateRule(c.CreateRule)
c.UpdateRule = migrateRule(c.UpdateRule)
c.DeleteRule = migrateRule(c.DeleteRule)
// migrate fields
// ---
for i, field := range c.Schema {
switch cast.ToString(field["type"]) {
case "bool":
field = toBoolField(field)
case "number":
field = toNumberField(field)
case "text":
field = toTextField(field)
case "url":
field = toURLField(field)
case "email":
field = toEmailField(field)
case "editor":
field = toEditorField(field)
case "date":
field = toDateField(field)
case "select":
field = toSelectField(field)
case "json":
field = toJSONField(field)
case "relation":
field = toRelationField(field)
case "file":
field = toFileField(field)
}
c.Schema[i] = field
}
// type specific changes
switch c.Type {
case "auth":
// token configs
// ---
c.Options["authToken"] = map[string]any{
"secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordAuthToken", "secret")), dummyAuthCollection.AuthToken.Secret),
"duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordAuthToken", "duration")), dummyAuthCollection.AuthToken.Duration),
}
c.Options["passwordResetToken"] = map[string]any{
"secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordPasswordResetToken", "secret")), dummyAuthCollection.PasswordResetToken.Secret),
"duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordPasswordResetToken", "duration")), dummyAuthCollection.PasswordResetToken.Duration),
}
c.Options["emailChangeToken"] = map[string]any{
"secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordEmailChangeToken", "secret")), dummyAuthCollection.EmailChangeToken.Secret),
"duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordEmailChangeToken", "duration")), dummyAuthCollection.EmailChangeToken.Duration),
}
c.Options["verificationToken"] = map[string]any{
"secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordVerificationToken", "secret")), dummyAuthCollection.VerificationToken.Secret),
"duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordVerificationToken", "duration")), dummyAuthCollection.VerificationToken.Duration),
}
c.Options["fileToken"] = map[string]any{
"secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordFileToken", "secret")), dummyAuthCollection.FileToken.Secret),
"duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordFileToken", "duration")), dummyAuthCollection.FileToken.Duration),
}
onlyVerified := cast.ToBool(options["onlyVerified"])
if onlyVerified {
c.Options["authRule"] = "verified=true"
} else {
c.Options["authRule"] = ""
}
c.Options["manageRule"] = nil
if options["manageRule"] != nil {
manageRule, err := cast.ToStringE(options["manageRule"])
if err == nil && manageRule != "" {
c.Options["manageRule"] = migrateRule(&manageRule)
}
}
// passwordAuth
identityFields := []string{}
if cast.ToBool(options["allowEmailAuth"]) {
identityFields = append(identityFields, "email")
}
if cast.ToBool(options["allowUsernameAuth"]) {
identityFields = append(identityFields, "username")
}
c.Options["passwordAuth"] = map[string]any{
"enabled": len(identityFields) > 0,
"identityFields": identityFields,
}
// oauth2
// ---
oauth2Providers := []map[string]any{}
providerNames := []string{
"googleAuth",
"facebookAuth",
"githubAuth",
"gitlabAuth",
"discordAuth",
"twitterAuth",
"microsoftAuth",
"spotifyAuth",
"kakaoAuth",
"twitchAuth",
"stravaAuth",
"giteeAuth",
"livechatAuth",
"giteaAuth",
"oidcAuth",
"oidc2Auth",
"oidc3Auth",
"appleAuth",
"instagramAuth",
"vkAuth",
"yandexAuth",
"patreonAuth",
"mailcowAuth",
"bitbucketAuth",
"planningcenterAuth",
}
for _, name := range providerNames {
if !cast.ToBool(getMapVal(oldSettings.Value, name, "enabled")) {
continue
}
oauth2Providers = append(oauth2Providers, map[string]any{
"name": strings.TrimSuffix(name, "Auth"),
"clientId": cast.ToString(getMapVal(oldSettings.Value, name, "clientId")),
"clientSecret": cast.ToString(getMapVal(oldSettings.Value, name, "clientSecret")),
"authURL": cast.ToString(getMapVal(oldSettings.Value, name, "authUrl")),
"tokenURL": cast.ToString(getMapVal(oldSettings.Value, name, "tokenUrl")),
"userInfoURL": cast.ToString(getMapVal(oldSettings.Value, name, "userApiUrl")),
"displayName": cast.ToString(getMapVal(oldSettings.Value, name, "displayName")),
"pkce": getMapVal(oldSettings.Value, name, "pkce"),
})
}
c.Options["oauth2"] = map[string]any{
"enabled": cast.ToBool(options["allowOAuth2Auth"]) && len(oauth2Providers) > 0,
"providers": oauth2Providers,
"mappedFields": map[string]string{
"username": "username",
},
}
// default email templates
// ---
emailTemplates := map[string]core.EmailTemplate{
"verificationTemplate": dummyAuthCollection.VerificationTemplate,
"resetPasswordTemplate": dummyAuthCollection.ResetPasswordTemplate,
"confirmEmailChangeTemplate": dummyAuthCollection.ConfirmEmailChangeTemplate,
}
for name, fallback := range emailTemplates {
c.Options[name] = map[string]any{
"subject": zeroFallback(
cast.ToString(getMapVal(oldSettings.Value, "meta", name, "subject")),
fallback.Subject,
),
"body": zeroFallback(
strings.ReplaceAll(
cast.ToString(getMapVal(oldSettings.Value, "meta", name, "body")),
"{ACTION_URL}",
cast.ToString(getMapVal(oldSettings.Value, "meta", name, "actionUrl")),
),
fallback.Body,
),
}
}
// mfa
// ---
c.Options["mfa"] = map[string]any{
"enabled": dummyAuthCollection.MFA.Enabled,
"duration": dummyAuthCollection.MFA.Duration,
"rule": dummyAuthCollection.MFA.Rule,
}
// otp
// ---
c.Options["otp"] = map[string]any{
"enabled": dummyAuthCollection.OTP.Enabled,
"duration": dummyAuthCollection.OTP.Duration,
"length": dummyAuthCollection.OTP.Length,
"emailTemplate": map[string]any{
"subject": dummyAuthCollection.OTP.EmailTemplate.Subject,
"body": dummyAuthCollection.OTP.EmailTemplate.Body,
},
}
// auth alerts
// ---
c.Options["authAlert"] = map[string]any{
"enabled": dummyAuthCollection.AuthAlert.Enabled,
"emailTemplate": map[string]any{
"subject": dummyAuthCollection.AuthAlert.EmailTemplate.Subject,
"body": dummyAuthCollection.AuthAlert.EmailTemplate.Body,
},
}
// add system field indexes
// ---
c.Indexes = append(types.JSONArray[string]{
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_username_idx` ON `%s` (username COLLATE NOCASE)", c.Id, c.Name),
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_email_idx` ON `%s` (`email`) WHERE `email` != ''", c.Id, c.Name),
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_tokenKey_idx` ON `%s` (`tokenKey`)", c.Id, c.Name),
}, c.Indexes...)
// prepend the auth system fields
// ---
tokenKeyField := map[string]any{
"id": fieldIdChecksum("text", "tokenKey"),
"type": "text",
"name": "tokenKey",
"system": true,
"hidden": true,
"required": true,
"presentable": false,
"primaryKey": false,
"min": 30,
"max": 60,
"pattern": "",
"autogeneratePattern": "[a-zA-Z0-9_]{50}",
}
passwordField := map[string]any{
"id": fieldIdChecksum("password", "password"),
"type": "password",
"name": "password",
"presentable": false,
"system": true,
"hidden": true,
"required": true,
"pattern": "",
"min": cast.ToInt(options["minPasswordLength"]),
"cost": bcrypt.DefaultCost, // new default
}
emailField := map[string]any{
"id": fieldIdChecksum("email", "email"),
"type": "email",
"name": "email",
"system": true,
"hidden": false,
"presentable": false,
"required": cast.ToBool(options["requireEmail"]),
"exceptDomains": cast.ToStringSlice(options["exceptEmailDomains"]),
"onlyDomains": cast.ToStringSlice(options["onlyEmailDomains"]),
}
emailVisibilityField := map[string]any{
"id": fieldIdChecksum("bool", "emailVisibility"),
"type": "bool",
"name": "emailVisibility",
"system": true,
"hidden": false,
"presentable": false,
"required": false,
}
verifiedField := map[string]any{
"id": fieldIdChecksum("bool", "verified"),
"type": "bool",
"name": "verified",
"system": true,
"hidden": false,
"presentable": false,
"required": false,
}
usernameField := map[string]any{
"id": fieldIdChecksum("text", "username"),
"type": "text",
"name": "username",
"system": false,
"hidden": false,
"required": true,
"presentable": false,
"primaryKey": false,
"min": 3,
"max": 150,
"pattern": `^[\w][\w\.\-]*$`,
"autogeneratePattern": "users[0-9]{6}",
}
c.Schema = append(types.JSONArray[types.JSONMap[any]]{
passwordField,
tokenKeyField,
emailField,
emailVisibilityField,
verifiedField,
usernameField,
}, c.Schema...)
// rename passwordHash records rable column to password
// ---
_, err = txApp.DB().RenameColumn(c.Name, "passwordHash", "password").Execute()
if err != nil {
return err
}
// delete unnecessary auth columns
dropColumns := []string{"lastResetSentAt", "lastVerificationSentAt", "lastLoginAlertSentAt"}
for _, drop := range dropColumns {
// ignore errors in case the columns don't exist
_, _ = txApp.DB().DropColumn(c.Name, drop).Execute()
}
case "view":
c.Options["viewQuery"] = cast.ToString(options["query"])
}
// prepend the id field
idField := map[string]any{
"id": fieldIdChecksum("text", "id"),
"type": "text",
"name": "id",
"system": true,
"required": true,
"presentable": false,
"hidden": false,
"primaryKey": true,
"min": 15,
"max": 15,
"pattern": "^[a-z0-9]+$",
"autogeneratePattern": "[a-z0-9]{15}",
}
c.Schema = append(types.JSONArray[types.JSONMap[any]]{idField}, c.Schema...)
var addCreated, addUpdated bool
if c.Type == "view" {
// manually check if the view has created/updated columns
columns, _ := txApp.TableColumns(c.Name)
for _, c := range columns {
if strings.EqualFold(c, "created") {
addCreated = true
} else if strings.EqualFold(c, "updated") {
addUpdated = true
}
}
} else {
addCreated = true
addUpdated = true
}
if addCreated {
createdField := map[string]any{
"id": fieldIdChecksum("autodate", "created"),
"type": "autodate",
"name": "created",
"system": false,
"presentable": false,
"hidden": false,
"onCreate": true,
"onUpdate": false,
}
c.Schema = append(c.Schema, createdField)
}
if addUpdated {
updatedField := map[string]any{
"id": fieldIdChecksum("autodate", "updated"),
"type": "autodate",
"name": "updated",
"system": false,
"presentable": false,
"hidden": false,
"onCreate": true,
"onUpdate": true,
}
c.Schema = append(c.Schema, updatedField)
}
if err = txApp.DB().Model(c).Update(); err != nil {
return err
}
}
_, err = txApp.DB().RenameColumn("_collections", "schema", "fields").Execute()
if err != nil {
return err
}
// run collection validations
collections, err := txApp.FindAllCollections()
if err != nil {
return fmt.Errorf("failed to retrieve all collections: %w", err)
}
for _, c := range collections {
err = txApp.Validate(c)
if err != nil {
return fmt.Errorf("migrated collection %q validation failure: %w", c.Name, err)
}
}
return nil
}
type OldCollectionModel struct {
Id string `db:"id" json:"id"`
Created types.DateTime `db:"created" json:"created"`
Updated types.DateTime `db:"updated" json:"updated"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
System bool `db:"system" json:"system"`
Schema types.JSONArray[types.JSONMap[any]] `db:"schema" json:"schema"`
Indexes types.JSONArray[string] `db:"indexes" json:"indexes"`
ListRule *string `db:"listRule" json:"listRule"`
ViewRule *string `db:"viewRule" json:"viewRule"`
CreateRule *string `db:"createRule" json:"createRule"`
UpdateRule *string `db:"updateRule" json:"updateRule"`
DeleteRule *string `db:"deleteRule" json:"deleteRule"`
Options types.JSONMap[any] `db:"options" json:"options"`
}
func (c OldCollectionModel) TableName() string {
return "_collections"
}
func migrateRule(rule *string) *string {
if rule == nil {
return nil
}
str := strings.ReplaceAll(*rule, "@request.data", "@request.body")
return &str
}
func toBoolField(data map[string]any) map[string]any {
return map[string]any{
"type": "bool",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
}
}
func toNumberField(data map[string]any) map[string]any {
return map[string]any{
"type": "number",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"onlyInt": cast.ToBool(getMapVal(data, "options", "noDecimal")),
"min": getMapVal(data, "options", "min"),
"max": getMapVal(data, "options", "max"),
}
}
func toTextField(data map[string]any) map[string]any {
return map[string]any{
"type": "text",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"primaryKey": cast.ToBool(data["primaryKey"]),
"hidden": cast.ToBool(data["hidden"]),
"presentable": cast.ToBool(data["presentable"]),
"required": cast.ToBool(data["required"]),
"min": cast.ToInt(getMapVal(data, "options", "min")),
"max": cast.ToInt(getMapVal(data, "options", "max")),
"pattern": cast.ToString(getMapVal(data, "options", "pattern")),
"autogeneratePattern": cast.ToString(getMapVal(data, "options", "autogeneratePattern")),
}
}
func toEmailField(data map[string]any) map[string]any {
return map[string]any{
"type": "email",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"exceptDomains": cast.ToStringSlice(getMapVal(data, "options", "exceptDomains")),
"onlyDomains": cast.ToStringSlice(getMapVal(data, "options", "onlyDomains")),
}
}
func toURLField(data map[string]any) map[string]any {
return map[string]any{
"type": "url",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"exceptDomains": cast.ToStringSlice(getMapVal(data, "options", "exceptDomains")),
"onlyDomains": cast.ToStringSlice(getMapVal(data, "options", "onlyDomains")),
}
}
func toEditorField(data map[string]any) map[string]any {
return map[string]any{
"type": "editor",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"convertURLs": cast.ToBool(getMapVal(data, "options", "convertUrls")),
}
}
func toDateField(data map[string]any) map[string]any {
return map[string]any{
"type": "date",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"min": cast.ToString(getMapVal(data, "options", "min")),
"max": cast.ToString(getMapVal(data, "options", "max")),
}
}
func toJSONField(data map[string]any) map[string]any {
return map[string]any{
"type": "json",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"maxSize": cast.ToInt64(getMapVal(data, "options", "maxSize")),
}
}
func toSelectField(data map[string]any) map[string]any {
return map[string]any{
"type": "select",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"values": cast.ToStringSlice(getMapVal(data, "options", "values")),
"maxSelect": cast.ToInt(getMapVal(data, "options", "maxSelect")),
}
}
func toRelationField(data map[string]any) map[string]any {
maxSelect := cast.ToInt(getMapVal(data, "options", "maxSelect"))
if maxSelect <= 0 {
maxSelect = 2147483647
}
return map[string]any{
"type": "relation",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"collectionId": cast.ToString(getMapVal(data, "options", "collectionId")),
"cascadeDelete": cast.ToBool(getMapVal(data, "options", "cascadeDelete")),
"minSelect": cast.ToInt(getMapVal(data, "options", "minSelect")),
"maxSelect": maxSelect,
}
}
func toFileField(data map[string]any) map[string]any {
return map[string]any{
"type": "file",
"id": cast.ToString(data["id"]),
"name": cast.ToString(data["name"]),
"system": cast.ToBool(data["system"]),
"required": cast.ToBool(data["required"]),
"presentable": cast.ToBool(data["presentable"]),
"hidden": false,
"maxSelect": cast.ToInt(getMapVal(data, "options", "maxSelect")),
"maxSize": cast.ToInt64(getMapVal(data, "options", "maxSize")),
"thumbs": cast.ToStringSlice(getMapVal(data, "options", "thumbs")),
"mimeTypes": cast.ToStringSlice(getMapVal(data, "options", "mimeTypes")),
"protected": cast.ToBool(getMapVal(data, "options", "protected")),
}
}
func getMapVal(m map[string]any, keys ...string) any {
if len(keys) == 0 {
return nil
}
result, ok := m[keys[0]]
if !ok {
return nil
}
// end key reached
if len(keys) == 1 {
return result
}
if m, ok = result.(map[string]any); !ok {
return nil
}
return getMapVal(m, keys[1:]...)
}
func zeroFallback[T comparable](v T, fallback T) T {
var zero T
if v == zero {
return fallback
}
return v
}
@@ -0,0 +1,35 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
)
// note: this migration will be deleted in future version
func init_disabled4() {
core.SystemMigrations.Register(func(txApp core.App) error {
_, err := txApp.DB().NewQuery("CREATE INDEX IF NOT EXISTS idx__collections_type on {{_collections}} ([[type]]);").Execute()
if err != nil {
return err
}
// reset mfas and otps delete rule
collectionNames := []string{core.CollectionNameMFAs, core.CollectionNameOTPs}
for _, name := range collectionNames {
col, err := txApp.FindCollectionByNameOrId(name)
if err != nil {
return err
}
if col.DeleteRule != nil {
col.DeleteRule = nil
err = txApp.SaveNoValidate(col)
if err != nil {
return err
}
}
}
return nil
}, nil)
}
@@ -0,0 +1,145 @@
package migrations
import (
"hash/crc32"
"strconv"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// note: this migration will be deleted in future version
func collectionIdChecksum(typ, name string) string {
return "obc_" + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(typ+name))), 10)
}
func fieldIdChecksum(typ, name string) string {
return typ + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(name))), 10)
}
// normalize system collection and field ids
func init_disabled5() {
core.SystemMigrations.Register(func(txApp core.App) error {
collections := []*core.Collection{}
err := txApp.CollectionQuery().
AndWhere(dbx.In(
"name",
core.CollectionNameMFAs,
core.CollectionNameOTPs,
core.CollectionNameExternalAuths,
core.CollectionNameAuthOrigins,
core.CollectionNameSuperusers,
)).
All(&collections)
if err != nil {
return err
}
for _, c := range collections {
var needUpdate bool
references, err := txApp.FindCollectionReferences(c, c.Id)
if err != nil {
return err
}
authOrigins, err := txApp.FindAllAuthOriginsByCollection(c)
if err != nil {
return err
}
mfas, err := txApp.FindAllMFAsByCollection(c)
if err != nil {
return err
}
otps, err := txApp.FindAllOTPsByCollection(c)
if err != nil {
return err
}
originalId := c.Id
// normalize collection id
if checksum := collectionIdChecksum(c.Type, c.Name); c.Id != checksum {
c.Id = checksum
needUpdate = true
}
// normalize system fields
for _, f := range c.Fields {
if !f.GetSystem() {
continue
}
if checksum := fieldIdChecksum(f.Type(), f.GetName()); f.GetId() != checksum {
f.SetId(checksum)
needUpdate = true
}
}
if !needUpdate {
continue
}
rawExport, err := c.DBExport(txApp)
if err != nil {
return err
}
_, err = txApp.DB().Update("_collections", rawExport, dbx.HashExp{"id": originalId}).Execute()
if err != nil {
return err
}
// make sure that the cached collection id is also updated
cached, err := txApp.FindCachedCollectionByNameOrId(c.Name)
if err != nil {
return err
}
cached.Id = c.Id
// update collection references
for refCollection, fields := range references {
for _, f := range fields {
relationField, ok := f.(*core.RelationField)
if !ok || relationField.CollectionId == originalId {
continue
}
relationField.CollectionId = c.Id
}
if err = txApp.SaveNoValidate(refCollection); err != nil {
return err
}
}
// update mfas references
for _, item := range mfas {
item.SetCollectionRef(c.Id)
if err = txApp.SaveNoValidate(item); err != nil {
return err
}
}
// update otps references
for _, item := range otps {
item.SetCollectionRef(c.Id)
if err = txApp.SaveNoValidate(item); err != nil {
return err
}
}
// update authOrigins references
for _, item := range authOrigins {
item.SetCollectionRef(c.Id)
if err = txApp.SaveNoValidate(item); err != nil {
return err
}
}
}
return nil
}, nil)
}
@@ -0,0 +1,30 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
)
// note: this migration will be deleted in future version
// add new OTP sentTo text field (if not already)
func init_disabled6() {
core.SystemMigrations.Register(func(txApp core.App) error {
otpCollection, err := txApp.FindCollectionByNameOrId(core.CollectionNameOTPs)
if err != nil {
return err
}
field := otpCollection.Fields.GetByName("sentTo")
if field != nil {
return nil // already exists
}
otpCollection.Fields.Add(&core.TextField{
Name: "sentTo",
System: true,
Hidden: true,
})
return txApp.Save(otpCollection)
}, nil)
}
@@ -0,0 +1,62 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
)
const oldAuthAlertTemplate = `<p>Hello,</p>
<p>We noticed a login to your {APP_NAME} account from a new location.</p>
<p>If this was you, you may disregard this email.</p>
<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>
<p>
Thanks,<br/>
{APP_NAME} team
</p>`
func init() {
core.SystemMigrations.Register(func(txApp core.App) error {
collections, err := txApp.FindAllCollections(core.CollectionTypeAuth)
if err != nil {
return err
}
newTemplate := core.NewAuthCollection("up").AuthAlert.EmailTemplate.Body
for _, c := range collections {
if c.AuthAlert.EmailTemplate.Body != oldAuthAlertTemplate {
continue
}
c.AuthAlert.EmailTemplate.Body = newTemplate
err = txApp.Save(c)
if err != nil {
return err
}
}
return nil
}, func(txApp core.App) error {
collections, err := txApp.FindAllCollections(core.CollectionTypeAuth)
if err != nil {
return err
}
newTemplate := core.NewAuthCollection("down").AuthAlert.EmailTemplate.Body
for _, c := range collections {
if c.AuthAlert.EmailTemplate.Body != newTemplate {
continue
}
c.AuthAlert.EmailTemplate.Body = oldAuthAlertTemplate
err = txApp.Save(c)
if err != nil {
return err
}
}
return nil
})
}
@@ -0,0 +1,51 @@
package migrations
import (
"strings"
"github.com/pocketbase/dbx"
)
// isPostgres checks if the provided database builder is using the PostgreSQL driver.
func isPostgres(db dbx.Builder) bool {
// We check if the table pg_proc exists, which is a system table in PostgreSQL.
// This is a reliable way to detect PostgreSQL at runtime without needing
// access to the unexported driver name in dbx.
var exists bool
err := db.NewQuery("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'pg_proc')").Row(&exists)
if err != nil {
// If the query fails, it's likely not PostgreSQL (e.g. SQLite doesn't have information_schema.tables)
return false
}
// Note: in PostgreSQL specifically, pg_proc is in pg_catalog, but information_schema works too
// Actually, let's try a simpler one:
_, err = db.NewQuery("SELECT version()").Execute()
if err != nil {
return false
}
return true
}
// getDriverName returns the driver name for the provided database builder.
func getDriverName(db dbx.Builder) string {
// PostgreSQL supports 'SELECT version()'
var version string
err := db.NewQuery("SELECT version()").Row(&version)
if err == nil {
if strings.Contains(strings.ToLower(version), "postgres") {
return "postgres"
}
// Assume MySQL if version() works but not postgres
// (MariaDB also returns version string usually without postgres)
return "mysql"
}
// SQLite supports 'SELECT sqlite_version()'
var sqliteVersion string
err = db.NewQuery("SELECT sqlite_version()").Row(&sqliteVersion)
if err == nil {
return "sqlite"
}
return "unknown"
}
@@ -0,0 +1,103 @@
package migrations
import "github.com/pocketbase/dbx"
func createSQLiteEquivalentFunctions(db dbx.Builder) error {
if getDriverName(db) != "postgres" {
return nil
}
//PostgreSQL:
// 1. Check existance
sql := `SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'uuid_generate_v7');`
var exists bool
if err := db.NewQuery(sql).Row(&exists); err != nil {
return err
} else if exists {
// The function already exists, no need to create it again
return nil
}
// Postgres:
// 2. Create function
funcDef := `
-- Enable built-in pgcrypto extension to use gen_random_bytes function
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Adding "nocase" collation to be compatible with SQLite's built-in "nocase" collation
CREATE COLLATION IF NOT EXISTS "nocase" (
provider = icu, -- Specify ICU as the provider
locale = 'und-u-ks-level2', -- Undetermined locale, Unicode extension (-u-), collation strength (ks) level 2 (level2)
deterministic = false -- Case-insensitive collations are typically non-deterministic
);
-- Alias [hex] to encode(..., 'hex')
CREATE OR REPLACE FUNCTION hex(data bytea)
RETURNS text
LANGUAGE SQL
IMMUTABLE
AS $$
SELECT encode(data, 'hex')
$$;
-- Alias [randomblob] to gen_random_bytes(...)
CREATE OR REPLACE FUNCTION randomblob(length integer)
RETURNS bytea
LANGUAGE SQL
IMMUTABLE
AS $$
SELECT gen_random_bytes(length)
$$;
-- Create the uuid_generate_v7 function
create or replace function uuid_generate_v7()
returns uuid
as $$
begin
-- use random v4 uuid as starting point (which has the same variant we need)
-- then overlay timestamp
-- then set version 7 by flipping the 2 and 1 bit in the version 4 string
return encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
end
$$
language plpgsql
volatile;
-- Create json_valid function
CREATE OR REPLACE FUNCTION json_valid(text) RETURNS boolean AS $$
BEGIN
PERFORM $1::jsonb;
RETURN TRUE;
EXCEPTION WHEN others THEN
RETURN FALSE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Create a json_query_or_null function that handles any types.
CREATE OR REPLACE FUNCTION json_query_or_null(p_input jsonb, p_query text) RETURNS jsonb AS $$
SELECT JSON_QUERY(p_input, p_query)
$$ LANGUAGE sql IMMUTABLE;
-- Create a json_query_or_null function that handles any types.
CREATE OR REPLACE FUNCTION json_query_or_null(p_input anyelement, p_query text) RETURNS jsonb AS $$
BEGIN
RETURN JSON_QUERY(p_input::text::jsonb, p_query);
EXCEPTION WHEN others THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
`
_, err := db.NewQuery(funcDef).Execute()
return err
}