start working on struct validation for gtsmodel

This commit is contained in:
tsmethurst 2021-08-29 16:52:23 +02:00
parent 53507ac2a3
commit 8aa72f995f
10 changed files with 407 additions and 125 deletions

View file

@ -20,19 +20,14 @@
import "time" import "time"
// StatusMute refers to one account having muted the status of another account or its own // StatusMute refers to one account having muted the status of another account or its own.
type StatusMute struct { type StatusMute struct {
// id of this mute in the database ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
ID string `bun:"type:CHAR(26),pk,notnull,unique"` CreatedAt time.Time `validate:"required" bun:",nullzero,notnull,default:current_timestamp"` // when was item created
// when was this mute created AccountID string `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull"` // id of the account that created ('did') the mute
CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"` Account *Account `validate:"-" bun:"rel:belongs-to"` // pointer to the account specified by accountID
// id of the account that created ('did') the mute TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull"` // id the account owning the muted status (can be the same as accountID)
AccountID string `bun:"type:CHAR(26),notnull"` TargetAccount *Account `validate:"-" bun:"rel:belongs-to"` // pointer to the account specified by targetAccountID
Account *Account `bun:"rel:belongs-to"` StatusID string `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull"` // database id of the status that has been muted
// id the account owning the muted status (can be the same as accountID) Status *Status `validate:"-" bun:"rel:belongs-to"` // pointer to the muted status specified by statusID
TargetAccountID string `bun:"type:CHAR(26),notnull"`
TargetAccount *Account `bun:"rel:belongs-to"`
// database id of the status that has been muted
StatusID string `bun:"type:CHAR(26),notnull"`
Status *Status `bun:"rel:belongs-to"`
} }

View file

@ -20,24 +20,15 @@
import "time" import "time"
// Tag represents a hashtag for gathering public statuses together // Tag represents a hashtag for gathering public statuses together.
type Tag struct { type Tag struct {
// id of this tag in the database ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
ID string `bun:",unique,type:CHAR(26),pk,notnull"` CreatedAt time.Time `validate:"required" bun:",nullzero,notnull,default:current_timestamp"` // when was item created
// Href of this tag, eg https://example.org/tags/somehashtag UpdatedAt time.Time `validate:"required" bun:",nullzero,notnull,default:current_timestamp"` // when was item last updated
URL string `bun:",nullzero"` URL string `validate:"required,url" bun:",nullzero,notnull"` // Href of this tag, eg https://example.org/tags/somehashtag
// name of this tag -- the tag without the hash part Name string `validate:"required" bun:",unique,nullzero,notnull"` // name of this tag -- the tag without the hash part
Name string `bun:",unique,notnull"` FirstSeenFromAccountID string `validate:"ulid" bun:"type:CHAR(26),nullzero"` // Which account ID is the first one we saw using this tag?
// Which account ID is the first one we saw using this tag? Useable bool `validate:"-" bun:",nullzero,notnull,default:true"` // can our instance users use this tag?
FirstSeenFromAccountID string `bun:"type:CHAR(26),nullzero"` Listable bool `validate:"-" bun:",nullzero,notnull,default:true"` // can our instance users look up this tag?
// when was this tag created LastStatusAt time.Time `validate:"required" bun:",nullzero,notnull,default:current_timestamp"` // when was this tag last used?
CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
// when was this tag last updated
UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
// can our instance users use this tag?
Useable bool `bun:",notnull,default:true"`
// can our instance users look up this tag?
Listable bool `bun:",notnull,default:true"`
// when was this tag last used?
LastStatusAt time.Time `bun:",nullzero"`
} }

View file

@ -0,0 +1,92 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
package gtsmodel_test
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func happyTag() *gtsmodel.Tag {
return &gtsmodel.Tag{
ID: "01FE91RJR88PSEEE30EV35QR8N",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
URL: "https://example.org/tags/some_tag",
Name: "some_tag",
FirstSeenFromAccountID: "01FE91SR5P2GW06K3AJ98P72MT",
Useable: true,
Listable: true,
LastStatusAt: time.Now(),
}
}
type TagValidateTestSuite struct {
suite.Suite
}
func (suite *TagValidateTestSuite) TestValidateTagHappyPath() {
// no problem here
t := happyTag()
err := gtsmodel.ValidateStruct(*t)
suite.NoError(err)
}
func (suite *TagValidateTestSuite) TestValidateTagNoName() {
t := happyTag()
t.Name = ""
err := gtsmodel.ValidateStruct(*t)
suite.EqualError(err, "Key: 'Tag.Name' Error:Field validation for 'Name' failed on the 'required' tag")
}
func (suite *TagValidateTestSuite) TestValidateTagBadURL() {
t := happyTag()
t.URL = ""
err := gtsmodel.ValidateStruct(*t)
suite.EqualError(err, "Key: 'Tag.URL' Error:Field validation for 'URL' failed on the 'required' tag")
t.URL = "no-schema.com"
err = gtsmodel.ValidateStruct(*t)
suite.EqualError(err, "Key: 'Tag.URL' Error:Field validation for 'URL' failed on the 'url' tag")
t.URL = "justastring"
err = gtsmodel.ValidateStruct(*t)
suite.EqualError(err, "Key: 'Tag.URL' Error:Field validation for 'URL' failed on the 'url' tag")
t.URL = "https://aaa\n\n\naaaaaaaa"
err = gtsmodel.ValidateStruct(*t)
suite.EqualError(err, "Key: 'Tag.URL' Error:Field validation for 'URL' failed on the 'url' tag")
}
func (suite *TagValidateTestSuite) TestValidateTagNoFirstSeenFromAccountID() {
t := happyTag()
t.FirstSeenFromAccountID = ""
err := gtsmodel.ValidateStruct(*t)
suite.NoError(err)
}
func TestTagValidateTestSuite(t *testing.T) {
suite.Run(t, new(TagValidateTestSuite))
}

View file

@ -26,97 +26,45 @@
// User represents an actual human user of gotosocial. Note, this is a LOCAL gotosocial user, not a remote account. // User represents an actual human user of gotosocial. Note, this is a LOCAL gotosocial user, not a remote account.
// To cross reference this local user with their account (which can be local or remote), use the AccountID field. // To cross reference this local user with their account (which can be local or remote), use the AccountID field.
type User struct { type User struct {
/* ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
BASIC INFO CreatedAt time.Time `validate:"required" bun:",nullzero,notnull,default:current_timestamp"` // when was item created
*/ UpdatedAt time.Time `validate:"required" bun:",nullzero,notnull,default:current_timestamp"` // when was item last updated
Email string `validate:"required_with=ConfirmedAt" bun:",nullzero,notnull,unique"` // confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported
AccountID string `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull,unique"` // The id of the local gtsmodel.Account entry for this user.
Account *Account `validate:"-" bun:"rel:belongs-to"` // Pointer to the account of this user that corresponds to AccountID.
EncryptedPassword string `validate:"required" bun:",nullzero,notnull"` // The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables.
SignUpIP net.IP `validate:"-" bun:",nullzero"` // From what IP was this user created?
CurrentSignInAt time.Time `validate:"-" bun:",nullzero"` // When did the user sign in with their current session.
CurrentSignInIP net.IP `validate:"-" bun:",nullzero"` // What's the most recent IP of this user
LastSignInAt time.Time `validate:"-" bun:",nullzero"` // When did this user last sign in?
LastSignInIP net.IP `validate:"-" bun:",nullzero"` // What's the previous IP of this user?
SignInCount int `validate:"-" bun:",nullzero,notnull,default:0"` // How many times has this user signed in?
InviteID string `validate:"ulid" bun:"type:CHAR(26),nullzero"` // id of the user who invited this user (who let this joker in?)
ChosenLanguages []string `validate:"-" bun:",nullzero"` // What languages does this user want to see?
FilteredLanguages []string `validate:"-" bun:",nullzero"` // What languages does this user not want to see?
Locale string `validate:"-" bun:",nullzero"` // In what timezone/locale is this user located?
CreatedByApplicationID string `validate:"ulid" bun:"type:CHAR(26),nullzero,notnull"` // Which application id created this user? See gtsmodel.Application
CreatedByApplication *Application `validate:"-" bun:"rel:belongs-to"` // Pointer to the application corresponding to createdbyapplicationID.
LastEmailedAt time.Time `validate:"-" bun:",nullzero"` // When was this user last contacted by email.
ConfirmationToken string `validate:"required_with=ConfirmationSentAt" bun:",nullzero"` // What confirmation token did we send this user/what are we expecting back?
ConfirmationSentAt time.Time `validate:"required_with=ConfirmationToken" bun:",nullzero"` // When did we send email confirmation to this user?
ConfirmedAt time.Time `validate:"required_with=Email" bun:",nullzero"` // When did the user confirm their email address
UnconfirmedEmail string `validate:"required_without=Email" bun:",nullzero"` // Email address that hasn't yet been confirmed
Moderator bool `validate:"-" bun:",nullzero,notnull,default:false"` // Is this user a moderator?
Admin bool `validate:"-" bun:",nullzero,notnull,default:false"` // Is this user an admin?
Disabled bool `validate:"-" bun:",nullzero,notnull,default:false"` // Is this user disabled from posting?
Approved bool `validate:"-" bun:",nullzero,notnull,default:false"` // Has this user been approved by a moderator?
ResetPasswordToken string `validate:"required_with=ResetPasswordSentAt" bun:",nullzero"` // The generated token that the user can use to reset their password
ResetPasswordSentAt time.Time `validate:"required_with=ResetPasswordToken" bun:",nullzero"` // When did we email the user their reset-password email?
// id of this user in the local database; the end-user will never need to know this, it's strictly internal EncryptedOTPSecret string `validate:"-" bun:",nullzero"`
ID string `bun:"type:CHAR(26),pk,notnull,unique"` EncryptedOTPSecretIv string `validate:"-" bun:",nullzero"`
// confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported EncryptedOTPSecretSalt string `validate:"-" bun:",nullzero"`
Email string `bun:"default:null,unique,nullzero"` OTPRequiredForLogin bool `validate:"-" bun:",nullzero"`
// The id of the local gtsmodel.Account entry for this user, if it exists (unconfirmed users don't have an account yet) OTPBackupCodes []string `validate:"-" bun:",nullzero"`
AccountID string `bun:"type:CHAR(26),unique,nullzero"` ConsumedTimestamp int `validate:"-" bun:",nullzero"`
Account *Account `bun:"rel:belongs-to"` RememberToken string `validate:"-" bun:",nullzero"`
// The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables SignInToken string `validate:"-" bun:",nullzero"`
EncryptedPassword string `bun:",notnull"` SignInTokenSentAt time.Time `validate:"-" bun:",nullzero"`
WebauthnID string `validate:"-" bun:",nullzero"`
/*
USER METADATA
*/
// When was this user created?
CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
// From what IP was this user created?
SignUpIP net.IP `bun:",nullzero"`
// When was this user updated (eg., password changed, email address changed)?
UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
// When did this user sign in for their current session?
CurrentSignInAt time.Time `bun:",nullzero"`
// What's the most recent IP of this user
CurrentSignInIP net.IP `bun:",nullzero"`
// When did this user last sign in?
LastSignInAt time.Time `bun:",nullzero"`
// What's the previous IP of this user?
LastSignInIP net.IP `bun:",nullzero"`
// How many times has this user signed in?
SignInCount int
// id of the user who invited this user (who let this guy in?)
InviteID string `bun:"type:CHAR(26),nullzero"`
// What languages does this user want to see?
ChosenLanguages []string
// What languages does this user not want to see?
FilteredLanguages []string
// In what timezone/locale is this user located?
Locale string `bun:",nullzero"`
// Which application id created this user? See gtsmodel.Application
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"`
CreatedByApplication *Application `bun:"rel:belongs-to"`
// When did we last contact this user
LastEmailedAt time.Time `bun:",nullzero"`
/*
USER CONFIRMATION
*/
// What confirmation token did we send this user/what are we expecting back?
ConfirmationToken string `bun:",nullzero"`
// When did the user confirm their email address
ConfirmedAt time.Time `bun:",nullzero"`
// When did we send email confirmation to this user?
ConfirmationSentAt time.Time `bun:",nullzero"`
// Email address that hasn't yet been confirmed
UnconfirmedEmail string `bun:",nullzero"`
/*
ACL FLAGS
*/
// Is this user a moderator?
Moderator bool
// Is this user an admin?
Admin bool
// Is this user disabled from posting?
Disabled bool
// Has this user been approved by a moderator?
Approved bool
/*
USER SECURITY
*/
// The generated token that the user can use to reset their password
ResetPasswordToken string `bun:",nullzero"`
// When did we email the user their reset-password email?
ResetPasswordSentAt time.Time `bun:",nullzero"`
EncryptedOTPSecret string `bun:",nullzero"`
EncryptedOTPSecretIv string `bun:",nullzero"`
EncryptedOTPSecretSalt string `bun:",nullzero"`
OTPRequiredForLogin bool
OTPBackupCodes []string
ConsumedTimestamp int
RememberToken string `bun:",nullzero"`
SignInToken string `bun:",nullzero"`
SignInTokenSentAt time.Time `bun:",nullzero"`
WebauthnID string `bun:",nullzero"`
} }

View file

@ -0,0 +1,106 @@
package gtsmodel_test
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func happyUser() *gtsmodel.User {
return &gtsmodel.User{
ID: "01FE8TTK9F34BR0KG7639AJQTX",
Email: "whatever@example.org",
AccountID: "01FE8TWA7CN8J7237K5DFS1RY5",
Account: nil,
EncryptedPassword: "$2y$10$tkRapNGW.RWkEuCMWdgArunABFvsPGRvFQY3OibfSJo0RDL3z8WfC",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SignUpIP: net.ParseIP("128.64.32.16"),
CurrentSignInAt: time.Now(),
CurrentSignInIP: net.ParseIP("128.64.32.16"),
LastSignInAt: time.Now(),
LastSignInIP: net.ParseIP("128.64.32.16"),
SignInCount: 0,
InviteID: "",
ChosenLanguages: []string{},
FilteredLanguages: []string{},
Locale: "en",
CreatedByApplicationID: "01FE8Y5EHMWCA1MHMTNHRVZ1X4",
CreatedByApplication: nil,
LastEmailedAt: time.Now(),
ConfirmationToken: "",
ConfirmedAt: time.Now(),
ConfirmationSentAt: time.Now(),
UnconfirmedEmail: "",
Moderator: false,
Admin: false,
Disabled: false,
Approved: true,
}
}
type UserValidateTestSuite struct {
suite.Suite
}
func (suite *UserValidateTestSuite) TestValidateUserHappyPath() {
// no problem here
u := happyUser()
err := gtsmodel.ValidateStruct(*u)
suite.NoError(err)
}
func (suite *UserValidateTestSuite) TestValidateUserNoID() {
// user has no id set
u := happyUser()
u.ID = ""
err := gtsmodel.ValidateStruct(*u)
suite.EqualError(err, "Key: 'User.ID' Error:Field validation for 'ID' failed on the 'required' tag")
}
func (suite *UserValidateTestSuite) TestValidateUserNoEmail() {
// user has no email or unconfirmed email set
u := happyUser()
u.Email = ""
err := gtsmodel.ValidateStruct(*u)
suite.EqualError(err, "Key: 'User.Email' Error:Field validation for 'Email' failed on the 'required_with' tag\nKey: 'User.UnconfirmedEmail' Error:Field validation for 'UnconfirmedEmail' failed on the 'required_without' tag")
}
func (suite *UserValidateTestSuite) TestValidateUserOnlyUnconfirmedEmail() {
// user has only UnconfirmedEmail but ConfirmedAt is set
u := happyUser()
u.Email = ""
u.UnconfirmedEmail = "whatever@example.org"
err := gtsmodel.ValidateStruct(*u)
suite.EqualError(err, "Key: 'User.Email' Error:Field validation for 'Email' failed on the 'required_with' tag")
}
func (suite *UserValidateTestSuite) TestValidateUserOnlyUnconfirmedEmailOK() {
// user has only UnconfirmedEmail and ConfirmedAt is not set
u := happyUser()
u.Email = ""
u.UnconfirmedEmail = "whatever@example.org"
u.ConfirmedAt = time.Time{}
err := gtsmodel.ValidateStruct(*u)
suite.NoError(err)
}
func (suite *UserValidateTestSuite) TestValidateUserNoConfirmedAt() {
// user has Email but no ConfirmedAt
u := happyUser()
u.ConfirmedAt = time.Time{}
err := gtsmodel.ValidateStruct(*u)
suite.EqualError(err, "Key: 'User.ConfirmedAt' Error:Field validation for 'ConfirmedAt' failed on the 'required_with' tag")
}
func TestUserValidateTestSuite(t *testing.T) {
suite.Run(t, new(UserValidateTestSuite))
}

View file

@ -0,0 +1,78 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
package gtsmodel
import (
"reflect"
"github.com/go-playground/validator/v10"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
var v *validator.Validate
const (
PointerValidationPanic = "validate function was passed pointer"
InvalidValidationPanic = "validate function was passed invalid item"
)
var ulidValidator = func(fl validator.FieldLevel) bool {
value, kind, _ := fl.ExtractType(fl.Field())
if kind != reflect.String {
return false
}
// we want either an empty string, or a proper ULID, nothing else
// if the string is empty, the `required` tag will take care of it so we don't need to worry about it here
s := value.String()
if len(s) == 0 {
return true
}
return util.ValidateULID(s)
}
func init() {
v = validator.New()
v.RegisterValidation("ulid", ulidValidator)
}
func ValidateStruct(s interface{}) error {
switch reflect.ValueOf(s).Kind() {
case reflect.Invalid:
panic(InvalidValidationPanic)
case reflect.Ptr:
panic(PointerValidationPanic)
}
err := v.Struct(s)
return processValidationError(err)
}
func processValidationError(err error) error {
if err == nil {
return nil
}
if ive, ok := err.(*validator.InvalidValidationError); ok {
panic(ive)
}
return err.(validator.ValidationErrors)
}

View file

@ -0,0 +1,64 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
package gtsmodel_test
import (
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type ValidateTestSuite struct {
suite.Suite
}
func (suite *ValidateTestSuite) TestValidatePointer() {
var nilUser *gtsmodel.User
suite.PanicsWithValue(gtsmodel.PointerValidationPanic, func() {
gtsmodel.ValidateStruct(nilUser)
})
}
func (suite *ValidateTestSuite) TestValidateNil() {
suite.PanicsWithValue(gtsmodel.InvalidValidationPanic, func() {
gtsmodel.ValidateStruct(nil)
})
}
func (suite *ValidateTestSuite) TestValidateWeirdULID() {
type a struct {
ID bool `validate:"required,ulid"`
}
err := gtsmodel.ValidateStruct(a{ID: true})
suite.Error(err)
}
func (suite *ValidateTestSuite) TestValidateNotStruct() {
type aaaaaaa string
aaaaaa := aaaaaaa("aaaa")
suite.Panics(func() {
gtsmodel.ValidateStruct(aaaaaa)
})
}
func TestValidateTestSuite(t *testing.T) {
suite.Run(t, new(ValidateTestSuite))
}

View file

@ -10,6 +10,8 @@
const randomRange = 631152381 // ~20 years in seconds const randomRange = 631152381 // ~20 years in seconds
type ULID string
// NewULID returns a new ULID string using the current time, or an error if something goes wrong. // NewULID returns a new ULID string using the current time, or an error if something goes wrong.
func NewULID() (string, error) { func NewULID() (string, error) {
newUlid, err := ulid.New(ulid.Timestamp(time.Now()), rand.Reader) newUlid, err := ulid.New(ulid.Timestamp(time.Now()), rand.Reader)

View file

@ -90,6 +90,7 @@
followPathRegex = regexp.MustCompile(followPathRegexString) followPathRegex = regexp.MustCompile(followPathRegexString)
ulidRegexString = `[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}` ulidRegexString = `[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}`
ulidRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, ulidRegexString))
likedPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, LikedPath) likedPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, LikedPath)
// likedPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked // likedPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked

View file

@ -171,3 +171,8 @@ func ValidateSiteTerms(t string) error {
return nil return nil
} }
// ValidateULID returns true if the passed string is a valid ULID.
func ValidateULID(i string) bool {
return ulidRegex.MatchString(i)
}