diff --git a/README.md b/README.md index 8d090cbc1..b844976be 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,13 @@ The following libraries and frameworks are used by GoToSocial, with gratitude * [go-fed/activity](https://github.com/go-fed/activity); Golang ActivityPub/ActivityStreams library. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). * [go-fed/httpsig](https://github.com/go-fed/httpsig); secure HTTP signature library. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). * [google/uuid](https://github.com/google/uuid); UUID generation. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html) +* [go-playground/validator](https://github.com/go-playground/validator); struct validation. [MIT License](https://spdx.org/licenses/MIT.html) * [gorilla/websocket](https://github.com/gorilla/websocket); Websocket connectivity. [BSD-2-Clause License](https://spdx.org/licenses/BSD-2-Clause.html). * [h2non/filetype](https://github.com/h2non/filetype); filetype checking. [MIT License](https://spdx.org/licenses/MIT.html). * [jackc/pgx](https://github.com/jackc/pgx); Postgres driver. [MIT License](https://spdx.org/licenses/MIT.html). +* [modernc.org/sqlite](https://gitlab.com/cznic/sqlite); cgo-free port of SQLite. [Other License](https://gitlab.com/cznic/sqlite/-/blob/master/LICENSE). + * [modernc.org/ccgo](https://gitlab.com/cznic/ccgo); c99 AST -> Go translater. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). + * [modernc.org/libc](https://gitlab.com/cznic/libc); C-runtime services. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). * [microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday); HTML user-input sanitization. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). * [mvdan/xurls](https://github.com/mvdan/xurls); URL parsing regular expressions. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). * [nfnt/resize](https://github.com/nfnt/resize); convenient image resizing. [ISC License](https://spdx.org/licenses/ISC.html). @@ -146,11 +150,9 @@ The following libraries and frameworks are used by GoToSocial, with gratitude * [go-swagger/go-swagger](https://github.com/go-swagger/go-swagger); Swagger OpenAPI spec generation. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html). * [tdewolff/minify](https://github.com/tdewolff/minify); HTML minification. [MIT License](https://spdx.org/licenses/MIT.html). * [uptrace/bun](https://github.com/uptrace/bun); database ORM. [BSD-2-Clause License](https://spdx.org/licenses/BSD-2-Clause.html). + * * [urfave/cli](https://github.com/urfave/cli); command-line interface framework. [MIT License](https://spdx.org/licenses/MIT.html). * [wagslane/go-password-validator](https://github.com/wagslane/go-password-validator); password strength validation. [MIT License](https://spdx.org/licenses/MIT.html). -* [modernc.org/sqlite](sqlite); cgo-free port of SQLite. [Other License](https://gitlab.com/cznic/sqlite/-/blob/master/LICENSE). - * [modernc.org/ccgo](ccgo); c99 AST -> Go translater. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). - * [modernc.org/libc](libc); C-runtime services. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). ### Image Attribution diff --git a/go.mod b/go.mod index 9e0ef9a08..8cdd6baf7 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/go-errors/errors v1.4.0 // indirect github.com/go-fed/activity v1.0.1-0.20210803212804-d866ba75dd0f github.com/go-fed/httpsig v1.1.0 - github.com/go-playground/validator/v10 v10.7.0 // indirect + github.com/go-playground/validator/v10 v10.7.0 github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.2 // indirect diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index 248232fe3..ee26eeee1 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -37,11 +37,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/cache" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/uptrace/bun" "github.com/uptrace/bun/dialect/pgdialect" "github.com/uptrace/bun/dialect/sqlitedialect" + "github.com/uptrace/bun/migrate" _ "modernc.org/sqlite" ) @@ -73,6 +75,29 @@ type bunDBService struct { conn *DBConn } +func doMigration(ctx context.Context, db *bun.DB, log *logrus.Logger) error { + l := log.WithField("func", "doMigration") + + migrator := migrate.NewMigrator(db, migrations.Migrations) + + if err := migrator.Init(ctx); err != nil { + return err + } + + group, err := migrator.Migrate(ctx) + if err != nil { + return err + } + + if group.ID == 0 { + l.Info("there are no new migrations to run") + return nil + } + + l.Infof("MIGRATED DATABASE TO %s", group) + return nil +} + // NewBunDBService returns a bunDB derived from the provided config, which implements the go-fed DB interface. // Under the hood, it uses https://github.com/uptrace/bun to create and maintain a database connection. func NewBunDBService(ctx context.Context, c *config.Config, log *logrus.Logger) (db.DB, error) { @@ -121,6 +146,10 @@ func NewBunDBService(ctx context.Context, c *config.Config, log *logrus.Logger) conn.RegisterModel(t) } + if err := doMigration(ctx, conn.DB, log); err != nil { + return nil, fmt.Errorf("db migration error: %s", err) + } + ps := &bunDBService{ Account: &accountDB{ config: c, diff --git a/internal/db/bundb/migrations/20210816411877_struct_validation.go b/internal/db/bundb/migrations/20210816411877_struct_validation.go new file mode 100644 index 000000000..660d94517 --- /dev/null +++ b/internal/db/bundb/migrations/20210816411877_struct_validation.go @@ -0,0 +1,54 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + _, err := tx.NewCreateTable().Model(>smodel.Account{}).IfNotExists().Exec(ctx) + if err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + _, err := tx.NewDropTable().Model(>smodel.Account{}).Exec(ctx) + if err != nil { + return err + } + + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/migrations/README.md b/internal/db/bundb/migrations/README.md new file mode 100644 index 000000000..e293d43c7 --- /dev/null +++ b/internal/db/bundb/migrations/README.md @@ -0,0 +1,21 @@ +# Migrations + +## How do I write a migration file? + +[See here](https://bun.uptrace.dev/guide/migrations.html#migration-names) + +As a template, take one of the existing migration files and modify it. It will be automatically loaded and handled by Bun. + +## File format + +Bun requires a very specific format: 14 digits, then letters or underscores. + +You can use the following bash command on your branch to generate a suitable migration filename. + +```bash +echo "$(date --utc +%Y%m%H%M%S%N | head -c 14)_$(git rev-parse --abbrev-ref HEAD).go" +``` + +## Rules of thumb + +1. **DON'T DROP TABLES**!!!!!!!! diff --git a/internal/db/bundb/migrations/main.go b/internal/db/bundb/migrations/main.go new file mode 100644 index 000000000..7f4e76027 --- /dev/null +++ b/internal/db/bundb/migrations/main.go @@ -0,0 +1,28 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package migrations + +import ( + "github.com/uptrace/bun/migrate" +) + +var ( + // Migrations provides migration logic for bun + Migrations = migrate.NewMigrations() +) diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index a59746be5..75ea02b4f 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -27,124 +27,56 @@ import ( "time" ) -// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc) +// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc). type Account struct { - /* - BASIC INFO - */ - - // id of this account in the local database - ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` - // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` - Username string `bun:",notnull,unique:userdomain,nullzero"` // username and domain should be unique *with* each other - // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. - Domain string `bun:",unique:userdomain,nullzero"` // username and domain should be unique *with* each other - - /* - ACCOUNT METADATA - */ - - // ID of the avatar as a media attachment - AvatarMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` - AvatarMediaAttachment *MediaAttachment `bun:"rel:belongs-to"` - // For a non-local account, where can the header be fetched? - AvatarRemoteURL string `bun:",nullzero"` - // ID of the header as a media attachment - HeaderMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` - HeaderMediaAttachment *MediaAttachment `bun:"rel:belongs-to"` - // For a non-local account, where can the header be fetched? - HeaderRemoteURL string `bun:",nullzero"` - // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. - DisplayName string `bun:",nullzero"` - // a key/value map of fields that this account has added to their profile - Fields []Field - // A note that this account has on their profile (ie., the account's bio/description of themselves) - Note string `bun:",nullzero"` - // Is this a memorial account, ie., has the user passed away? - Memorial bool `bun:",nullzero"` - // This account has moved this account id in the database - MovedToAccountID string `bun:"type:CHAR(26),nullzero"` - // When was this account created? - CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"` - // When was this account last updated? - UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"` - // Does this account identify itself as a bot? - Bot bool - // What reason was given for signing up when this account was created? - Reason string `bun:",nullzero"` - - /* - USER AND PRIVACY PREFERENCES - */ - - // Does this account need an approval for new followers? - Locked bool `bun:",default:true"` - // Should this account be shown in the instance's profile directory? - Discoverable bool `bun:",default:false"` - // Default post privacy for this account - Privacy Visibility `bun:",default:'public'"` - // Set posts from this account to sensitive by default? - Sensitive bool `bun:",default:false"` - // What language does this account post in? - Language string `bun:",default:'en'"` - - /* - ACTIVITYPUB THINGS - */ - - // What is the activitypub URI for this account discovered by webfinger? - URI string `bun:",unique,nullzero"` - // At which URL can we see the user account in a web browser? - URL string `bun:",unique,nullzero"` - // Last time this account was located using the webfinger API. - LastWebfingeredAt time.Time `bun:",nullzero"` - // Address of this account's activitypub inbox, for sending activity to - InboxURI string `bun:",unique,nullzero"` - // Address of this account's activitypub outbox - OutboxURI string `bun:",unique,nullzero"` - // URI for getting the following list of this account - FollowingURI string `bun:",unique,nullzero"` - // URI for getting the followers list of this account - FollowersURI string `bun:",unique,nullzero"` - // URL for getting the featured collection list of this account - FeaturedCollectionURI string `bun:",unique,nullzero"` - // What type of activitypub actor is this account? - ActorType string `bun:",nullzero"` - // This account is associated with x account id - AlsoKnownAs string `bun:",nullzero"` - - /* - CRYPTO FIELDS - */ - - // Privatekey for validating activitypub requests, will only be defined for local accounts - PrivateKey *rsa.PrivateKey - // Publickey for encoding activitypub requests, will be defined for both local and remote accounts - PublicKey *rsa.PublicKey - // Web-reachable location of this account's public key - PublicKeyURI string `bun:",nullzero"` - - /* - ADMIN FIELDS - */ - - // When was this account set to have all its media shown as sensitive? - SensitizedAt time.Time `bun:",nullzero"` - // When was this account silenced (eg., statuses only visible to followers, not public)? - SilencedAt time.Time `bun:",nullzero"` - // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) - SuspendedAt time.Time `bun:",nullzero"` - // Should we hide this account's collections? - HideCollections bool - // id of the database entry that caused this account to become suspended -- can be an account ID or a domain block ID - SuspensionOrigin string `bun:"type:CHAR(26),nullzero"` + ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item last updated + Username string `validate:"required" bun:",nullzero,notnull,unique:userdomain"` // Username of the account, should just be a string of [a-zA-Z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org``. Username and domain should be unique *with* each other + Domain string `validate:"omitempty,fqdn" bun:",nullzero,unique:userdomain"` // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. + AvatarMediaAttachmentID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // Database ID of the media attachment, if present + AvatarMediaAttachment *MediaAttachment `validate:"-" bun:"rel:belongs-to"` // MediaAttachment corresponding to avatarMediaAttachmentID + AvatarRemoteURL string `validate:"omitempty,url" bun:",nullzero"` // For a non-local account, where can the header be fetched? + HeaderMediaAttachmentID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // Database ID of the media attachment, if present + HeaderMediaAttachment *MediaAttachment `validate:"-" bun:"rel:belongs-to"` // MediaAttachment corresponding to headerMediaAttachmentID + HeaderRemoteURL string `validate:"omitempty,url" bun:",nullzero"` // For a non-local account, where can the header be fetched? + DisplayName string `validate:"-" bun:",nullzero"` // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. + Fields []Field `validate:"-"` // a key/value map of fields that this account has added to their profile + Note string `validate:"-" bun:",nullzero"` // A note that this account has on their profile (ie., the account's bio/description of themselves) + Memorial bool `validate:"-" bun:",nullzero,default:false"` // Is this a memorial account, ie., has the user passed away? + AlsoKnownAs string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // This account is associated with x account id + MovedToAccountID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // This account has moved this account id in the database + Bot bool `validate:"-" bun:",nullzero,default:false"` // Does this account identify itself as a bot? + Reason string `validate:"-" bun:",nullzero"` // What reason was given for signing up when this account was created? + Locked bool `validate:"-" bun:",nullzero,default:true"` // Does this account need an approval for new followers? + Discoverable bool `validate:"-" bun:",nullzero,default:false"` // Should this account be shown in the instance's profile directory? + Privacy Visibility `validate:"oneof=public unlocked followers_only mutuals_only direct" bun:",nullzero,notnull,default:'public'"` // Default post privacy for this account + Sensitive bool `validate:"-" bun:",nullzero,default:false"` // Set posts from this account to sensitive by default? + Language string `validate:"-" bun:",nullzero,notnull,default:'en'"` // What language does this account post in? + URI string `validate:"required,url" bun:",nullzero,notnull,unique"` // ActivityPub URI for this account. + URL string `validate:"omitempty,url" bun:",unique,nullzero"` // Web URL for this account's profile + LastWebfingeredAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // Last time this account was refreshed/located with webfinger. + InboxURI string `validate:"omitempty,url" bun:",nullzero,unique"` // Address of this account's ActivityPub inbox, for sending activity to + OutboxURI string `validate:"omitempty,url" bun:",nullzero,unique"` // Address of this account's activitypub outbox + FollowingURI string `validate:"omitempty,url" bun:",nullzero,unique"` // URI for getting the following list of this account + FollowersURI string `validate:"omitempty,url" bun:",nullzero,unique"` // URI for getting the followers list of this account + FeaturedCollectionURI string `validate:"omitempty,url" bun:",nullzero,unique"` // URL for getting the featured collection list of this account + ActorType string `validate:"oneof=Application Group Organization Person Service " bun:",nullzero,notnull"` // What type of activitypub actor is this account? + PrivateKey *rsa.PrivateKey `validate:"required_without=Domain"` // Privatekey for validating activitypub requests, will only be defined for local accounts + PublicKey *rsa.PublicKey `validate:"required"` // Publickey for encoding activitypub requests, will be defined for both local and remote accounts + PublicKeyURI string `validate:"required" bun:",nullzero,notnull"` // Web-reachable location of this account's public key + SensitizedAt time.Time `validate:"-" bun:",nullzero"` // When was this account set to have all its media shown as sensitive? + SilencedAt time.Time `validate:"-" bun:",nullzero"` // When was this account silenced (eg., statuses only visible to followers, not public)? + SuspendedAt time.Time `validate:"-" bun:",nullzero"` // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) + HideCollections bool `validate:"-" bun:",nullzero,default:false"` // Hide this account's collections + SuspensionOrigin string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // id of the database entry that caused this account to become suspended -- can be an account ID or a domain block ID } // Field represents a key value field on an account, for things like pronouns, website, etc. // VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the // username of the user. type Field struct { - Name string - Value string - VerifiedAt time.Time `bun:",nullzero"` + Name string `validate:"required"` // Name of this field. + Value string `validate:"required"` // Value of this field. + VerifiedAt time.Time `validate:"-" bun:",nullzero"` // This field was verified at (optional). } diff --git a/internal/gtsmodel/block.go b/internal/gtsmodel/block.go index 989dd8193..61595c12d 100644 --- a/internal/gtsmodel/block.go +++ b/internal/gtsmodel/block.go @@ -4,12 +4,12 @@ import "time" // Block refers to the blocking of one account by another. type Block struct { - ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database - CreatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item created - UpdatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item last updated - URI string `validate:"required,url" bun:",notnull,nullzero,unique"` // ActivityPub uri of this block. - AccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:frsrctarget,notnull"` // Who does this block originate from? - Account *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to accountID - TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:frsrctarget,notnull"` // Who is the target of this block ? - TargetAccount *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to targetAccountID + ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item last updated + URI string `validate:"required,url" bun:",notnull,nullzero,unique"` // ActivityPub uri of this block. + AccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:blocksrctarget,notnull"` // Who does this block originate from? + Account *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to accountID + TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:blocksrctarget,notnull"` // Who is the target of this block ? + TargetAccount *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to targetAccountID } diff --git a/internal/gtsmodel/block_test.go b/internal/gtsmodel/block_test.go new file mode 100644 index 000000000..307f26cd9 --- /dev/null +++ b/internal/gtsmodel/block_test.go @@ -0,0 +1,115 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package gtsmodel_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func happyBlock() *gtsmodel.Block { + return >smodel.Block{ + ID: "01FE91RJR88PSEEE30EV35QR8N", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + URI: "https://example.org/accounts/someone/blocks/01FE91RJR88PSEEE30EV35QR8N", + AccountID: "01FEED79PRMVWPRMFHFQM8MJQN", + Account: nil, + TargetAccountID: "01FEEDMF6C0QD589MRK7919Z0R", + TargetAccount: nil, + } +} + +type BlockValidateTestSuite struct { + suite.Suite +} + +func (suite *BlockValidateTestSuite) TestValidateBlockHappyPath() { + // no problem here + d := happyBlock() + err := gtsmodel.ValidateStruct(*d) + suite.NoError(err) +} + +func (suite *BlockValidateTestSuite) TestValidateBlockBadID() { + d := happyBlock() + + d.ID = "" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.ID' Error:Field validation for 'ID' failed on the 'required' tag") + + d.ID = "01FE96W293ZPRG9FQQP48HK8N001FE96W32AT24VYBGM12WN3GKB" + err = gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.ID' Error:Field validation for 'ID' failed on the 'ulid' tag") +} + +func (suite *BlockValidateTestSuite) TestValidateBlockNoCreatedAt() { + d := happyBlock() + + d.CreatedAt = time.Time{} + err := gtsmodel.ValidateStruct(*d) + suite.NoError(err) +} + +func (suite *BlockValidateTestSuite) TestValidateBlockCreatedByAccountID() { + d := happyBlock() + + d.AccountID = "" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.AccountID' Error:Field validation for 'AccountID' failed on the 'required' tag") + + d.AccountID = "this-is-not-a-valid-ulid" + err = gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.AccountID' Error:Field validation for 'AccountID' failed on the 'ulid' tag") +} + +func (suite *BlockValidateTestSuite) TestValidateBlockTargetAccountID() { + d := happyBlock() + + d.TargetAccountID = "invalid-ulid" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.TargetAccountID' Error:Field validation for 'TargetAccountID' failed on the 'ulid' tag") + + d.TargetAccountID = "01FEEDHX4G7EGHF5GD9E82Y51Q" + err = gtsmodel.ValidateStruct(*d) + suite.NoError(err) + + d.TargetAccountID = "" + err = gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.TargetAccountID' Error:Field validation for 'TargetAccountID' failed on the 'required' tag") +} + +func (suite *BlockValidateTestSuite) TestValidateBlockURI() { + d := happyBlock() + + d.URI = "invalid-uri" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.URI' Error:Field validation for 'URI' failed on the 'url' tag") + + d.URI = "" + err = gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'Block.URI' Error:Field validation for 'URI' failed on the 'required' tag") +} + +func TestBlockValidateTestSuite(t *testing.T) { + suite.Run(t, new(BlockValidateTestSuite)) +} diff --git a/internal/gtsmodel/domainblock_test.go b/internal/gtsmodel/domainblock_test.go new file mode 100644 index 000000000..ced29ab31 --- /dev/null +++ b/internal/gtsmodel/domainblock_test.go @@ -0,0 +1,121 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package gtsmodel_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func happyDomainBlock() *gtsmodel.DomainBlock { + return >smodel.DomainBlock{ + ID: "01FE91RJR88PSEEE30EV35QR8N", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Domain: "baddudes.suck", + CreatedByAccountID: "01FEED79PRMVWPRMFHFQM8MJQN", + PrivateComment: "we don't like em", + PublicComment: "poo poo dudes", + Obfuscate: false, + SubscriptionID: "", + } +} + +type DomainBlockValidateTestSuite struct { + suite.Suite +} + +func (suite *DomainBlockValidateTestSuite) TestValidateDomainBlockHappyPath() { + // no problem here + d := happyDomainBlock() + err := gtsmodel.ValidateStruct(*d) + suite.NoError(err) +} + +func (suite *DomainBlockValidateTestSuite) TestValidateDomainBlockBadID() { + d := happyDomainBlock() + + d.ID = "" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'DomainBlock.ID' Error:Field validation for 'ID' failed on the 'required' tag") + + d.ID = "01FE96W293ZPRG9FQQP48HK8N001FE96W32AT24VYBGM12WN3GKB" + err = gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'DomainBlock.ID' Error:Field validation for 'ID' failed on the 'ulid' tag") +} + +func (suite *DomainBlockValidateTestSuite) TestValidateDomainBlockNoCreatedAt() { + d := happyDomainBlock() + + d.CreatedAt = time.Time{} + err := gtsmodel.ValidateStruct(*d) + suite.NoError(err) +} + +func (suite *DomainBlockValidateTestSuite) TestValidateDomainBlockBadDomain() { + d := happyDomainBlock() + + d.Domain = "" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'DomainBlock.Domain' Error:Field validation for 'Domain' failed on the 'required' tag") + + d.Domain = "this-is-not-a-valid-domain" + err = gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'DomainBlock.Domain' Error:Field validation for 'Domain' failed on the 'fqdn' tag") +} + +func (suite *DomainBlockValidateTestSuite) TestValidateDomainBlockCreatedByAccountID() { + d := happyDomainBlock() + + d.CreatedByAccountID = "" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'DomainBlock.CreatedByAccountID' Error:Field validation for 'CreatedByAccountID' failed on the 'required' tag") + + d.CreatedByAccountID = "this-is-not-a-valid-ulid" + err = gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'DomainBlock.CreatedByAccountID' Error:Field validation for 'CreatedByAccountID' failed on the 'ulid' tag") +} + +func (suite *DomainBlockValidateTestSuite) TestValidateDomainBlockComments() { + d := happyDomainBlock() + + d.PrivateComment = "" + d.PublicComment = "" + err := gtsmodel.ValidateStruct(*d) + suite.NoError(err) +} + +func (suite *DomainBlockValidateTestSuite) TestValidateDomainSubscriptionID() { + d := happyDomainBlock() + + d.SubscriptionID = "invalid-ulid" + err := gtsmodel.ValidateStruct(*d) + suite.EqualError(err, "Key: 'DomainBlock.SubscriptionID' Error:Field validation for 'SubscriptionID' failed on the 'ulid' tag") + + d.SubscriptionID = "01FEEDHX4G7EGHF5GD9E82Y51Q" + err = gtsmodel.ValidateStruct(*d) + suite.NoError(err) +} + +func TestDomainBlockValidateTestSuite(t *testing.T) { + suite.Run(t, new(DomainBlockValidateTestSuite)) +} diff --git a/internal/gtsmodel/emaildomainblock_test.go b/internal/gtsmodel/emaildomainblock_test.go new file mode 100644 index 000000000..83d7c4c6a --- /dev/null +++ b/internal/gtsmodel/emaildomainblock_test.go @@ -0,0 +1,96 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package gtsmodel_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func happyEmailDomainBlock() *gtsmodel.EmailDomainBlock { + return >smodel.EmailDomainBlock{ + ID: "01FE91RJR88PSEEE30EV35QR8N", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Domain: "baddudes.suck", + CreatedByAccountID: "01FEED79PRMVWPRMFHFQM8MJQN", + } +} + +type EmailDomainBlockValidateTestSuite struct { + suite.Suite +} + +func (suite *EmailDomainBlockValidateTestSuite) TestValidateEmailDomainBlockHappyPath() { + // no problem here + e := happyEmailDomainBlock() + err := gtsmodel.ValidateStruct(*e) + suite.NoError(err) +} + +func (suite *EmailDomainBlockValidateTestSuite) TestValidateEmailDomainBlockBadID() { + e := happyEmailDomainBlock() + + e.ID = "" + err := gtsmodel.ValidateStruct(*e) + suite.EqualError(err, "Key: 'EmailDomainBlock.ID' Error:Field validation for 'ID' failed on the 'required' tag") + + e.ID = "01FE96W293ZPRG9FQQP48HK8N001FE96W32AT24VYBGM12WN3GKB" + err = gtsmodel.ValidateStruct(*e) + suite.EqualError(err, "Key: 'EmailDomainBlock.ID' Error:Field validation for 'ID' failed on the 'ulid' tag") +} + +func (suite *EmailDomainBlockValidateTestSuite) TestValidateEmailDomainBlockNoCreatedAt() { + e := happyEmailDomainBlock() + + e.CreatedAt = time.Time{} + err := gtsmodel.ValidateStruct(*e) + suite.NoError(err) +} + +func (suite *EmailDomainBlockValidateTestSuite) TestValidateEmailDomainBlockBadDomain() { + e := happyEmailDomainBlock() + + e.Domain = "" + err := gtsmodel.ValidateStruct(*e) + suite.EqualError(err, "Key: 'EmailDomainBlock.Domain' Error:Field validation for 'Domain' failed on the 'required' tag") + + e.Domain = "this-is-not-a-valid-domain" + err = gtsmodel.ValidateStruct(*e) + suite.EqualError(err, "Key: 'EmailDomainBlock.Domain' Error:Field validation for 'Domain' failed on the 'fqdn' tag") +} + +func (suite *EmailDomainBlockValidateTestSuite) TestValidateEmailDomainBlockCreatedByAccountID() { + e := happyEmailDomainBlock() + + e.CreatedByAccountID = "" + err := gtsmodel.ValidateStruct(*e) + suite.EqualError(err, "Key: 'EmailDomainBlock.CreatedByAccountID' Error:Field validation for 'CreatedByAccountID' failed on the 'required' tag") + + e.CreatedByAccountID = "this-is-not-a-valid-ulid" + err = gtsmodel.ValidateStruct(*e) + suite.EqualError(err, "Key: 'EmailDomainBlock.CreatedByAccountID' Error:Field validation for 'CreatedByAccountID' failed on the 'ulid' tag") +} + +func TestEmailDomainBlockValidateTestSuite(t *testing.T) { + suite.Run(t, new(EmailDomainBlockValidateTestSuite)) +} diff --git a/internal/gtsmodel/follow.go b/internal/gtsmodel/follow.go index 8b03db56a..c2b2633b9 100644 --- a/internal/gtsmodel/follow.go +++ b/internal/gtsmodel/follow.go @@ -26,10 +26,10 @@ type Follow struct { CreatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item created UpdatedAt time.Time `validate:"-" bun:",nullzero,notnull,default:current_timestamp"` // when was item last updated URI string `validate:"required,url" bun:",notnull,nullzero,unique"` // ActivityPub uri of this follow. - AccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:srctarget,notnull"` // Who does this follow originate from? + AccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:srctarget,notnull"` // Who does this follow originate from? Account *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to accountID - TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:srctarget,notnull"` // Who is the target of this follow ? + TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:srctarget,notnull"` // Who is the target of this follow ? TargetAccount *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to targetAccountID - ShowReblogs bool `validate:"-" bun:",nullzero,default:true"` // Does this follow also want to see reblogs and not just posts? + ShowReblogs bool `validate:"-" bun:",nullzero,default:true"` // Does this follow also want to see reblogs and not just posts? Notify bool `validate:"-" bun:",nullzero,default:false"` // does the following account want to be notified when the followed account posts? } diff --git a/internal/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go index c4b3c9997..ae22f6487 100644 --- a/internal/gtsmodel/followrequest.go +++ b/internal/gtsmodel/followrequest.go @@ -30,6 +30,6 @@ type FollowRequest struct { Account *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to accountID TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:frsrctarget,notnull"` // Who is the target of this follow request? TargetAccount *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to targetAccountID - ShowReblogs bool `validate:"-" bun:",nullzero,default:true"` // Does this follow also want to see reblogs and not just posts? + ShowReblogs bool `validate:"-" bun:",nullzero,default:true"` // Does this follow also want to see reblogs and not just posts? Notify bool `validate:"-" bun:",nullzero,default:false"` // does the following account want to be notified when the followed account posts? } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 002fa38c6..a7148ae17 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -261,10 +261,6 @@ func NewTestUsers() map[string]*gtsmodel.User { // NewTestAccounts returns a map of accounts keyed by what type of account they are. func NewTestAccounts() map[string]*gtsmodel.Account { accounts := map[string]*gtsmodel.Account{ - "instance_account": { - ID: "01F8MH261H1KSV3GW3016GZRY3", - Username: "localhost:8080", - }, "unconfirmed_account": { ID: "01F8MH0BBE4FHXPH513MBVFHB0", Username: "weed_lord420", diff --git a/vendor/github.com/uptrace/bun/migrate/migration.go b/vendor/github.com/uptrace/bun/migrate/migration.go new file mode 100644 index 000000000..79f13f972 --- /dev/null +++ b/vendor/github.com/uptrace/bun/migrate/migration.go @@ -0,0 +1,272 @@ +package migrate + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io/fs" + "sort" + "strings" + "time" + + "github.com/uptrace/bun" +) + +type Migration struct { + bun.BaseModel + + ID int64 + Name string + GroupID int64 + MigratedAt time.Time `bun:",notnull,nullzero,default:current_timestamp"` + + Up MigrationFunc `bun:"-"` + Down MigrationFunc `bun:"-"` +} + +func (m *Migration) String() string { + return m.Name +} + +func (m *Migration) IsApplied() bool { + return m.ID > 0 +} + +type MigrationFunc func(ctx context.Context, db *bun.DB) error + +func NewSQLMigrationFunc(fsys fs.FS, name string) MigrationFunc { + return func(ctx context.Context, db *bun.DB) error { + isTx := strings.HasSuffix(name, ".tx.up.sql") || strings.HasSuffix(name, ".tx.down.sql") + + f, err := fsys.Open(name) + if err != nil { + return err + } + + scanner := bufio.NewScanner(f) + var queries []string + + var query []byte + for scanner.Scan() { + b := scanner.Bytes() + + const prefix = "--bun:" + if bytes.HasPrefix(b, []byte(prefix)) { + b = b[len(prefix):] + if bytes.Equal(b, []byte("split")) { + queries = append(queries, string(query)) + query = query[:0] + continue + } + return fmt.Errorf("bun: unknown directive: %q", b) + } + + query = append(query, b...) + query = append(query, '\n') + } + + if len(query) > 0 { + queries = append(queries, string(query)) + } + if err := scanner.Err(); err != nil { + return err + } + + var idb bun.IConn + + if isTx { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + idb = tx + } else { + conn, err := db.Conn(ctx) + if err != nil { + return err + } + idb = conn + } + + for _, q := range queries { + _, err = idb.ExecContext(ctx, q) + if err != nil { + return err + } + } + + if tx, ok := idb.(bun.Tx); ok { + return tx.Commit() + } else if conn, ok := idb.(bun.Conn); ok { + return conn.Close() + } + + panic("not reached") + } +} + +const goTemplate = `package %s + +import ( + "context" + "fmt" + + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [up migration] ") + return nil + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] ") + return nil + }) +} +` + +const sqlTemplate = `SELECT 1 + +--bun:split + +SELECT 2 +` + +//------------------------------------------------------------------------------ + +type MigrationSlice []Migration + +func (ms MigrationSlice) String() string { + if len(ms) == 0 { + return "empty" + } + + if len(ms) > 5 { + return fmt.Sprintf("%d migrations (%s ... %s)", len(ms), ms[0].Name, ms[len(ms)-1].Name) + } + + var sb strings.Builder + + for i := range ms { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(ms[i].Name) + } + + return sb.String() +} + +// Applied returns applied migrations in descending order +// (the order is important and is used in Rollback). +func (ms MigrationSlice) Applied() MigrationSlice { + var applied MigrationSlice + for i := range ms { + if ms[i].IsApplied() { + applied = append(applied, ms[i]) + } + } + sortDesc(applied) + return applied +} + +// Unapplied returns unapplied migrations in ascending order +// (the order is important and is used in Migrate). +func (ms MigrationSlice) Unapplied() MigrationSlice { + var unapplied MigrationSlice + for i := range ms { + if !ms[i].IsApplied() { + unapplied = append(unapplied, ms[i]) + } + } + sortAsc(unapplied) + return unapplied +} + +// LastGroupID returns the last applied migration group id. +// The id is 0 when there are no migration groups. +func (ms MigrationSlice) LastGroupID() int64 { + var lastGroupID int64 + for i := range ms { + groupID := ms[i].GroupID + if groupID != 0 && groupID > lastGroupID { + lastGroupID = groupID + } + } + return lastGroupID +} + +// LastGroup returns the last applied migration group. +func (ms MigrationSlice) LastGroup() *MigrationGroup { + group := &MigrationGroup{ + ID: ms.LastGroupID(), + } + if group.ID == 0 { + return group + } + for i := range ms { + if ms[i].GroupID == group.ID { + group.Migrations = append(group.Migrations, ms[i]) + } + } + return group +} + +type MigrationGroup struct { + ID int64 + Migrations MigrationSlice +} + +func (g *MigrationGroup) IsZero() bool { + return g.ID == 0 && len(g.Migrations) == 0 +} + +func (g *MigrationGroup) String() string { + if g.IsZero() { + return "nil" + } + return fmt.Sprintf("group #%d (%s)", g.ID, g.Migrations) +} + +type MigrationFile struct { + Name string + Path string + Content string +} + +//------------------------------------------------------------------------------ + +type migrationConfig struct { + nop bool +} + +func newMigrationConfig(opts []MigrationOption) *migrationConfig { + cfg := new(migrationConfig) + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +type MigrationOption func(cfg *migrationConfig) + +func WithNopMigration() MigrationOption { + return func(cfg *migrationConfig) { + cfg.nop = true + } +} + +//------------------------------------------------------------------------------ + +func sortAsc(ms MigrationSlice) { + sort.Slice(ms, func(i, j int) bool { + return ms[i].Name < ms[j].Name + }) +} + +func sortDesc(ms MigrationSlice) { + sort.Slice(ms, func(i, j int) bool { + return ms[i].Name > ms[j].Name + }) +} diff --git a/vendor/github.com/uptrace/bun/migrate/migrations.go b/vendor/github.com/uptrace/bun/migrate/migrations.go new file mode 100644 index 000000000..9af861048 --- /dev/null +++ b/vendor/github.com/uptrace/bun/migrate/migrations.go @@ -0,0 +1,168 @@ +package migrate + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +type MigrationsOption func(m *Migrations) + +func WithMigrationsDirectory(directory string) MigrationsOption { + return func(m *Migrations) { + m.explicitDirectory = directory + } +} + +type Migrations struct { + ms MigrationSlice + + explicitDirectory string + implicitDirectory string +} + +func NewMigrations(opts ...MigrationsOption) *Migrations { + m := new(Migrations) + for _, opt := range opts { + opt(m) + } + m.implicitDirectory = filepath.Dir(migrationFile()) + return m +} + +func (m *Migrations) Sorted() MigrationSlice { + migrations := make(MigrationSlice, len(m.ms)) + copy(migrations, m.ms) + sortAsc(migrations) + return migrations +} + +func (m *Migrations) MustRegister(up, down MigrationFunc) { + if err := m.Register(up, down); err != nil { + panic(err) + } +} + +func (m *Migrations) Register(up, down MigrationFunc) error { + fpath := migrationFile() + name, err := extractMigrationName(fpath) + if err != nil { + return err + } + + m.Add(Migration{ + Name: name, + Up: up, + Down: down, + }) + + return nil +} + +func (m *Migrations) Add(migration Migration) { + if migration.Name == "" { + panic("migration name is required") + } + m.ms = append(m.ms, migration) +} + +func (m *Migrations) DiscoverCaller() error { + dir := filepath.Dir(migrationFile()) + return m.Discover(os.DirFS(dir)) +} + +func (m *Migrations) Discover(fsys fs.FS) error { + return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + if !strings.HasSuffix(path, ".up.sql") && !strings.HasSuffix(path, ".down.sql") { + return nil + } + + name, err := extractMigrationName(path) + if err != nil { + return err + } + + migration := m.getOrCreateMigration(name) + if err != nil { + return err + } + migrationFunc := NewSQLMigrationFunc(fsys, path) + + if strings.HasSuffix(path, ".up.sql") { + migration.Up = migrationFunc + return nil + } + if strings.HasSuffix(path, ".down.sql") { + migration.Down = migrationFunc + return nil + } + + return errors.New("migrate: not reached") + }) +} + +func (m *Migrations) getOrCreateMigration(name string) *Migration { + for i := range m.ms { + m := &m.ms[i] + if m.Name == name { + return m + } + } + + m.ms = append(m.ms, Migration{Name: name}) + return &m.ms[len(m.ms)-1] +} + +func (m *Migrations) getDirectory() string { + if m.explicitDirectory != "" { + return m.explicitDirectory + } + if m.implicitDirectory != "" { + return m.implicitDirectory + } + return filepath.Dir(migrationFile()) +} + +func migrationFile() string { + const depth = 32 + var pcs [depth]uintptr + n := runtime.Callers(1, pcs[:]) + frames := runtime.CallersFrames(pcs[:n]) + + for { + f, ok := frames.Next() + if !ok { + break + } + if !strings.Contains(f.Function, "/bun/migrate.") { + return f.File + } + } + + return "" +} + +var fnameRE = regexp.MustCompile(`^(\d{14})_[0-9a-z_\-]+\.`) + +func extractMigrationName(fpath string) (string, error) { + fname := filepath.Base(fpath) + + matches := fnameRE.FindStringSubmatch(fname) + if matches == nil { + return "", fmt.Errorf("migrate: unsupported migration name format: %q", fname) + } + + return matches[1], nil +} diff --git a/vendor/github.com/uptrace/bun/migrate/migrator.go b/vendor/github.com/uptrace/bun/migrate/migrator.go new file mode 100644 index 000000000..f9b4a51c2 --- /dev/null +++ b/vendor/github.com/uptrace/bun/migrate/migrator.go @@ -0,0 +1,401 @@ +package migrate + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "log" + "path/filepath" + "regexp" + "time" + + "github.com/uptrace/bun" +) + +type MigratorOption func(m *Migrator) + +func WithTableName(table string) MigratorOption { + return func(m *Migrator) { + m.table = table + } +} + +func WithLocksTableName(table string) MigratorOption { + return func(m *Migrator) { + m.locksTable = table + } +} + +type Migrator struct { + db *bun.DB + migrations *Migrations + + ms MigrationSlice + + table string + locksTable string +} + +func NewMigrator(db *bun.DB, migrations *Migrations, opts ...MigratorOption) *Migrator { + m := &Migrator{ + db: db, + migrations: migrations, + + ms: migrations.ms, + + table: "bun_migrations", + locksTable: "bun_migration_locks", + } + for _, opt := range opts { + opt(m) + } + return m +} + +func (m *Migrator) DB() *bun.DB { + return m.db +} + +// MigrationsWithStatus returns migrations with status in ascending order. +func (m *Migrator) MigrationsWithStatus(ctx context.Context) (MigrationSlice, error) { + sorted := m.migrations.Sorted() + + applied, err := m.selectAppliedMigrations(ctx) + if err != nil { + return nil, err + } + + appliedMap := migrationMap(applied) + for i := range sorted { + m1 := &sorted[i] + if m2, ok := appliedMap[m1.Name]; ok { + m1.ID = m2.ID + m1.GroupID = m2.GroupID + m1.MigratedAt = m2.MigratedAt + } + } + + return sorted, nil +} + +func (m *Migrator) Init(ctx context.Context) error { + if _, err := m.db.NewCreateTable(). + Model((*Migration)(nil)). + ModelTableExpr(m.table). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + if _, err := m.db.NewCreateTable(). + Model((*migrationLock)(nil)). + ModelTableExpr(m.locksTable). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + return nil +} + +func (m *Migrator) Reset(ctx context.Context) error { + if _, err := m.db.NewDropTable(). + Model((*Migration)(nil)). + ModelTableExpr(m.table). + IfExists(). + Exec(ctx); err != nil { + return err + } + if _, err := m.db.NewDropTable(). + Model((*migrationLock)(nil)). + ModelTableExpr(m.locksTable). + IfExists(). + Exec(ctx); err != nil { + return err + } + return m.Init(ctx) +} + +func (m *Migrator) Migrate(ctx context.Context, opts ...MigrationOption) (*MigrationGroup, error) { + cfg := newMigrationConfig(opts) + + if err := m.validate(); err != nil { + return nil, err + } + + if err := m.Lock(ctx); err != nil { + return nil, err + } + defer m.Unlock(ctx) //nolint:errcheck + + migrations, err := m.MigrationsWithStatus(ctx) + if err != nil { + return nil, err + } + + group := &MigrationGroup{ + Migrations: migrations.Unapplied(), + } + if len(group.Migrations) == 0 { + return group, nil + } + group.ID = migrations.LastGroupID() + 1 + + for i := range group.Migrations { + migration := &group.Migrations[i] + migration.GroupID = group.ID + + if !cfg.nop && migration.Up != nil { + if err := migration.Up(ctx, m.db); err != nil { + return nil, err + } + } + + if err := m.MarkApplied(ctx, migration); err != nil { + return nil, err + } + } + + return group, nil +} + +func (m *Migrator) Rollback(ctx context.Context, opts ...MigrationOption) (*MigrationGroup, error) { + cfg := newMigrationConfig(opts) + + if err := m.validate(); err != nil { + return nil, err + } + + if err := m.Lock(ctx); err != nil { + return nil, err + } + defer m.Unlock(ctx) //nolint:errcheck + + migrations, err := m.MigrationsWithStatus(ctx) + if err != nil { + return nil, err + } + + lastGroup := migrations.LastGroup() + + for i := len(lastGroup.Migrations) - 1; i >= 0; i-- { + migration := &lastGroup.Migrations[i] + + if !cfg.nop && migration.Down != nil { + if err := migration.Down(ctx, m.db); err != nil { + return nil, err + } + } + + if err := m.MarkUnapplied(ctx, migration); err != nil { + return nil, err + } + } + + return lastGroup, nil +} + +type MigrationStatus struct { + Migrations MigrationSlice + NewMigrations MigrationSlice + LastGroup *MigrationGroup +} + +func (m *Migrator) Status(ctx context.Context) (*MigrationStatus, error) { + log.Printf( + "DEPRECATED: bun: replace Status(ctx) with " + + "MigrationsWithStatus(ctx)") + + migrations, err := m.MigrationsWithStatus(ctx) + if err != nil { + return nil, err + } + return &MigrationStatus{ + Migrations: migrations, + NewMigrations: migrations.Unapplied(), + LastGroup: migrations.LastGroup(), + }, nil +} + +func (m *Migrator) MarkCompleted(ctx context.Context) (*MigrationGroup, error) { + log.Printf( + "DEPRECATED: bun: replace MarkCompleted(ctx) with " + + "Migrate(ctx, migrate.WithNopMigration())") + + return m.Migrate(ctx, WithNopMigration()) +} + +type goMigrationConfig struct { + packageName string +} + +type GoMigrationOption func(cfg *goMigrationConfig) + +func WithPackageName(name string) GoMigrationOption { + return func(cfg *goMigrationConfig) { + cfg.packageName = name + } +} + +// CreateGoMigration creates a Go migration file. +func (m *Migrator) CreateGoMigration( + ctx context.Context, name string, opts ...GoMigrationOption, +) (*MigrationFile, error) { + cfg := &goMigrationConfig{ + packageName: "migrations", + } + for _, opt := range opts { + opt(cfg) + } + + name, err := m.genMigrationName(name) + if err != nil { + return nil, err + } + + fname := name + ".go" + fpath := filepath.Join(m.migrations.getDirectory(), fname) + content := fmt.Sprintf(goTemplate, cfg.packageName) + + if err := ioutil.WriteFile(fpath, []byte(content), 0o644); err != nil { + return nil, err + } + + mf := &MigrationFile{ + Name: fname, + Path: fpath, + Content: content, + } + return mf, nil +} + +// CreateSQLMigrations creates an up and down SQL migration files. +func (m *Migrator) CreateSQLMigrations(ctx context.Context, name string) ([]*MigrationFile, error) { + name, err := m.genMigrationName(name) + if err != nil { + return nil, err + } + + up, err := m.createSQL(ctx, name+".up.sql") + if err != nil { + return nil, err + } + + down, err := m.createSQL(ctx, name+".down.sql") + if err != nil { + return nil, err + } + + return []*MigrationFile{up, down}, nil +} + +func (m *Migrator) createSQL(ctx context.Context, fname string) (*MigrationFile, error) { + fpath := filepath.Join(m.migrations.getDirectory(), fname) + + if err := ioutil.WriteFile(fpath, []byte(sqlTemplate), 0o644); err != nil { + return nil, err + } + + mf := &MigrationFile{ + Name: fname, + Path: fpath, + Content: goTemplate, + } + return mf, nil +} + +var nameRE = regexp.MustCompile(`^[0-9a-z_\-]+$`) + +func (m *Migrator) genMigrationName(name string) (string, error) { + const timeFormat = "20060102150405" + + if name == "" { + return "", errors.New("migrate: migration name can't be empty") + } + if !nameRE.MatchString(name) { + return "", fmt.Errorf("migrate: invalid migration name: %q", name) + } + + version := time.Now().UTC().Format(timeFormat) + return fmt.Sprintf("%s_%s", version, name), nil +} + +// MarkApplied marks the migration as applied (applied). +func (m *Migrator) MarkApplied(ctx context.Context, migration *Migration) error { + _, err := m.db.NewInsert().Model(migration). + ModelTableExpr(m.table). + Exec(ctx) + return err +} + +// MarkUnapplied marks the migration as unapplied (new). +func (m *Migrator) MarkUnapplied(ctx context.Context, migration *Migration) error { + _, err := m.db.NewDelete(). + Model(migration). + ModelTableExpr(m.table). + Where("id = ?", migration.ID). + Exec(ctx) + return err +} + +// selectAppliedMigrations selects applied (applied) migrations in descending order. +func (m *Migrator) selectAppliedMigrations(ctx context.Context) (MigrationSlice, error) { + var ms MigrationSlice + if err := m.db.NewSelect(). + ColumnExpr("*"). + Model(&ms). + ModelTableExpr(m.table). + Scan(ctx); err != nil { + return nil, err + } + return ms, nil +} + +func (m *Migrator) formattedTableName(db *bun.DB) string { + return db.Formatter().FormatQuery(m.table) +} + +func (m *Migrator) validate() error { + if len(m.ms) == 0 { + return errors.New("migrate: there are no any migrations") + } + return nil +} + +//------------------------------------------------------------------------------ + +type migrationLock struct { + ID int64 + TableName string `bun:",unique"` +} + +func (m *Migrator) Lock(ctx context.Context) error { + lock := &migrationLock{ + TableName: m.formattedTableName(m.db), + } + if _, err := m.db.NewInsert(). + Model(lock). + ModelTableExpr(m.locksTable). + Exec(ctx); err != nil { + return fmt.Errorf("migrate: migrations table is already locked (%w)", err) + } + return nil +} + +func (m *Migrator) Unlock(ctx context.Context) error { + tableName := m.formattedTableName(m.db) + _, err := m.db.NewDelete(). + Model((*migrationLock)(nil)). + ModelTableExpr(m.locksTable). + Where("? = ?", bun.Ident("table_name"), tableName). + Exec(ctx) + return err +} + +func migrationMap(ms MigrationSlice) map[string]*Migration { + mp := make(map[string]*Migration) + for i := range ms { + m := &ms[i] + mp[m.Name] = m + } + return mp +} diff --git a/vendor/modules.txt b/vendor/modules.txt index d98ddb2cf..f8bdb32e2 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -404,6 +404,7 @@ github.com/uptrace/bun/extra/bunjson github.com/uptrace/bun/internal github.com/uptrace/bun/internal/parser github.com/uptrace/bun/internal/tagparser +github.com/uptrace/bun/migrate github.com/uptrace/bun/schema # github.com/uptrace/bun/dialect/pgdialect v0.4.3 ## explicit