mirror of
https://github.com/pocketbase/pocketbase.git
synced 2025-01-09 01:17:51 +02:00
913 lines
30 KiB
Go
913 lines
30 KiB
Go
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() {
|
|
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
|
|
}
|