initial v0.8 pre-release

This commit is contained in:
Gani Georgiev
2022-10-30 10:28:14 +02:00
parent 9cbb2e750e
commit 90dba45d7c
388 changed files with 21580 additions and 13603 deletions
+57 -3
View File
@@ -1,13 +1,67 @@
package models
var _ Model = (*Admin)(nil)
import (
"errors"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/crypto/bcrypt"
)
var (
_ Model = (*Admin)(nil)
)
type Admin struct {
BaseAccount
BaseModel
Avatar int `db:"avatar" json:"avatar"`
Avatar int `db:"avatar" json:"avatar"`
Email string `db:"email" json:"email"`
TokenKey string `db:"tokenKey" json:"-"`
PasswordHash string `db:"passwordHash" json:"-"`
LastResetSentAt types.DateTime `db:"lastResetSentAt" json:"-"`
}
// TableName returns the Admin model SQL table name.
func (m *Admin) TableName() string {
return "_admins"
}
// ValidatePassword validates a plain password against the model's password.
func (m *Admin) ValidatePassword(password string) bool {
bytePassword := []byte(password)
bytePasswordHash := []byte(m.PasswordHash)
// comparing the password with the hash
err := bcrypt.CompareHashAndPassword(bytePasswordHash, bytePassword)
// nil means it is a match
return err == nil
}
// SetPassword sets cryptographically secure string to `model.Password`.
//
// Additionally this method also resets the LastResetSentAt and the TokenKey fields.
func (m *Admin) SetPassword(password string) error {
if password == "" {
return errors.New("The provided plain password is empty")
}
// hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
if err != nil {
return err
}
m.PasswordHash = string(hashedPassword)
m.LastResetSentAt = types.DateTime{} // reset
// invalidate previously issued tokens
return m.RefreshTokenKey()
}
// RefreshTokenKey generates and sets new random token key.
func (m *Admin) RefreshTokenKey() error {
m.TokenKey = security.RandomString(50)
return nil
}
+90
View File
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAdminTableName(t *testing.T) {
@@ -12,3 +13,92 @@ func TestAdminTableName(t *testing.T) {
t.Fatalf("Unexpected table name, got %q", m.TableName())
}
}
func TestAdminValidatePassword(t *testing.T) {
scenarios := []struct {
admin models.Admin
password string
expected bool
}{
{
// empty passwordHash + empty pass
models.Admin{},
"",
false,
},
{
// empty passwordHash + nonempty pass
models.Admin{},
"123456",
false,
},
{
// nonempty passwordHash + empty pass
models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
"",
false,
},
{
// nonempty passwordHash + wrong pass
models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
"654321",
false,
},
{
// nonempty passwordHash + correct pass
models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
"123456",
true,
},
}
for i, s := range scenarios {
result := s.admin.ValidatePassword(s.password)
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
func TestAdminSetPassword(t *testing.T) {
m := models.Admin{
// 123456
PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv.",
LastResetSentAt: types.NowDateTime(),
TokenKey: "test",
}
// empty pass
err1 := m.SetPassword("")
if err1 == nil {
t.Fatal("Expected empty password error")
}
err2 := m.SetPassword("654321")
if err2 != nil {
t.Fatalf("Expected nil, got error %v", err2)
}
if !m.ValidatePassword("654321") {
t.Fatalf("Password is invalid")
}
if m.TokenKey == "test" {
t.Fatalf("Expected TokenKey to change, got %v", m.TokenKey)
}
if !m.LastResetSentAt.IsZero() {
t.Fatalf("Expected LastResetSentAt to be zero datetime, got %v", m.LastResetSentAt)
}
}
func TestAdminRefreshTokenKey(t *testing.T) {
m := models.Admin{TokenKey: "test"}
m.RefreshTokenKey()
// empty pass
if m.TokenKey == "" || m.TokenKey == "test" {
t.Fatalf("Expected TokenKey to change, got %q", m.TokenKey)
}
}
+3 -57
View File
@@ -2,11 +2,8 @@
package models
import (
"errors"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/crypto/bcrypt"
)
const (
@@ -103,6 +100,9 @@ func (m *BaseModel) GetUpdated() types.DateTime {
//
// The generated id is a cryptographically random 15 characters length string.
func (m *BaseModel) RefreshId() {
if m.Id == "" { // no previous id
m.MarkAsNew()
}
m.Id = security.RandomStringWithAlphabet(DefaultIdLength, DefaultIdAlphabet)
}
@@ -115,57 +115,3 @@ func (m *BaseModel) RefreshCreated() {
func (m *BaseModel) RefreshUpdated() {
m.Updated = types.NowDateTime()
}
// -------------------------------------------------------------------
// BaseAccount
// -------------------------------------------------------------------
// BaseAccount defines common fields and methods used by auth models (aka. users and admins).
type BaseAccount struct {
BaseModel
Email string `db:"email" json:"email"`
TokenKey string `db:"tokenKey" json:"-"`
PasswordHash string `db:"passwordHash" json:"-"`
LastResetSentAt types.DateTime `db:"lastResetSentAt" json:"lastResetSentAt"`
}
// ValidatePassword validates a plain password against the model's password.
func (m *BaseAccount) ValidatePassword(password string) bool {
bytePassword := []byte(password)
bytePasswordHash := []byte(m.PasswordHash)
// comparing the password with the hash
err := bcrypt.CompareHashAndPassword(bytePasswordHash, bytePassword)
// nil means it is a match
return err == nil
}
// SetPassword sets cryptographically secure string to `model.Password`.
//
// Additionally this method also resets the LastResetSentAt and the TokenKey fields.
func (m *BaseAccount) SetPassword(password string) error {
if password == "" {
return errors.New("The provided plain password is empty")
}
// hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
if err != nil {
return err
}
m.PasswordHash = string(hashedPassword)
m.LastResetSentAt = types.DateTime{} // reset
// invalidate previously issued tokens
m.RefreshTokenKey()
return nil
}
// RefreshTokenKey generates and sets new random token key.
func (m *BaseAccount) RefreshTokenKey() {
m.TokenKey = security.RandomString(50)
}
+4 -94
View File
@@ -4,7 +4,6 @@ import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestBaseModelHasId(t *testing.T) {
@@ -65,6 +64,9 @@ func TestBaseModelIsNew(t *testing.T) {
m5 := models.BaseModel{Id: "test"}
m5.MarkAsNew()
m5.UnmarkAsNew()
// check if MarkAsNew will be called on initial RefreshId()
m6 := models.BaseModel{}
m6.RefreshId()
scenarios := []struct {
model models.BaseModel
@@ -76,6 +78,7 @@ func TestBaseModelIsNew(t *testing.T) {
{m3, true},
{m4, true},
{m5, false},
{m6, true},
}
for i, s := range scenarios {
@@ -113,96 +116,3 @@ func TestBaseModelUpdated(t *testing.T) {
t.Fatalf("Expected non-zero datetime, got %v", m.GetUpdated())
}
}
// -------------------------------------------------------------------
// BaseAccount tests
// -------------------------------------------------------------------
func TestBaseAccountValidatePassword(t *testing.T) {
scenarios := []struct {
account models.BaseAccount
password string
expected bool
}{
{
// empty passwordHash + empty pass
models.BaseAccount{},
"",
false,
},
{
// empty passwordHash + nonempty pass
models.BaseAccount{},
"123456",
false,
},
{
// nonempty passwordHash + empty pass
models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
"",
false,
},
{
// nonempty passwordHash + wrong pass
models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
"654321",
false,
},
{
// nonempty passwordHash + correct pass
models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
"123456",
true,
},
}
for i, s := range scenarios {
result := s.account.ValidatePassword(s.password)
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
func TestBaseAccountSetPassword(t *testing.T) {
m := models.BaseAccount{
// 123456
PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv.",
LastResetSentAt: types.NowDateTime(),
TokenKey: "test",
}
// empty pass
err1 := m.SetPassword("")
if err1 == nil {
t.Fatal("Expected empty password error")
}
err2 := m.SetPassword("654321")
if err2 != nil {
t.Fatalf("Expected nil, got error %v", err2)
}
if !m.ValidatePassword("654321") {
t.Fatalf("Password is invalid")
}
if m.TokenKey == "test" {
t.Fatalf("Expected TokenKey to change, got %v", m.TokenKey)
}
if !m.LastResetSentAt.IsZero() {
t.Fatalf("Expected LastResetSentAt to be zero datetime, got %v", m.LastResetSentAt)
}
}
func TestBaseAccountRefreshTokenKey(t *testing.T) {
m := models.BaseAccount{TokenKey: "test"}
m.RefreshTokenKey()
// empty pass
if m.TokenKey == "" || m.TokenKey == "test" {
t.Fatalf("Expected TokenKey to change, got %q", m.TokenKey)
}
}
+169 -11
View File
@@ -1,23 +1,43 @@
package models
import "github.com/pocketbase/pocketbase/models/schema"
import (
"encoding/json"
var _ Model = (*Collection)(nil)
var _ FilesManager = (*Collection)(nil)
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/types"
)
var (
_ Model = (*Collection)(nil)
_ FilesManager = (*Collection)(nil)
)
const (
CollectionTypeBase = "base"
CollectionTypeAuth = "auth"
)
type Collection struct {
BaseModel
Name string `db:"name" json:"name"`
System bool `db:"system" json:"system"`
Schema schema.Schema `db:"schema" json:"schema"`
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"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
System bool `db:"system" json:"system"`
Schema schema.Schema `db:"schema" json:"schema"`
// rules
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 `db:"options" json:"options"`
}
// TableName returns the Collection model SQL table name.
func (m *Collection) TableName() string {
return "_collections"
}
@@ -26,3 +46,141 @@ func (m *Collection) TableName() string {
func (m *Collection) BaseFilesPath() string {
return m.Id
}
// IsBase checks if the current collection has "base" type.
func (m *Collection) IsBase() bool {
return m.Type == CollectionTypeBase
}
// IsBase checks if the current collection has "auth" type.
func (m *Collection) IsAuth() bool {
return m.Type == CollectionTypeAuth
}
// MarshalJSON implements the [json.Marshaler] interface.
func (m Collection) MarshalJSON() ([]byte, error) {
type alias Collection // prevent recursion
m.NormalizeOptions()
return json.Marshal(alias(m))
}
// BaseOptions decodes the current collection options and returns them
// as new [CollectionBaseOptions] instance.
func (m *Collection) BaseOptions() CollectionBaseOptions {
result := CollectionBaseOptions{}
m.DecodeOptions(&result)
return result
}
// AuthOptions decodes the current collection options and returns them
// as new [CollectionAuthOptions] instance.
func (m *Collection) AuthOptions() CollectionAuthOptions {
result := CollectionAuthOptions{}
m.DecodeOptions(&result)
return result
}
// NormalizeOptions updates the current collection options with a
// new normalized state based on the collection type.
func (m *Collection) NormalizeOptions() error {
var typedOptions any
switch m.Type {
case CollectionTypeAuth:
typedOptions = m.AuthOptions()
default:
typedOptions = m.BaseOptions()
}
// serialize
raw, err := json.Marshal(typedOptions)
if err != nil {
return err
}
// load into a new JsonMap
m.Options = types.JsonMap{}
if err := json.Unmarshal(raw, &m.Options); err != nil {
return err
}
return nil
}
// DecodeOptions decodes the current collection options into the
// provided "result" (must be a pointer).
func (m *Collection) DecodeOptions(result any) error {
// raw serialize
raw, err := json.Marshal(m.Options)
if err != nil {
return err
}
// decode into the provided result
if err := json.Unmarshal(raw, result); err != nil {
return err
}
return nil
}
// SetOptions normalizes and unmarshals the specified options into m.Options.
func (m *Collection) SetOptions(typedOptions any) error {
// serialize
raw, err := json.Marshal(typedOptions)
if err != nil {
return err
}
m.Options = types.JsonMap{}
if err := json.Unmarshal(raw, &m.Options); err != nil {
return err
}
return m.NormalizeOptions()
}
// -------------------------------------------------------------------
// CollectionAuthOptions defines the "base" Collection.Options fields.
type CollectionBaseOptions struct {
}
// Validate implements [validation.Validatable] interface.
func (o CollectionBaseOptions) Validate() error {
return nil
}
// CollectionAuthOptions defines the "auth" Collection.Options fields.
type CollectionAuthOptions struct {
ManageRule *string `form:"manageRule" json:"manageRule"`
AllowOAuth2Auth bool `form:"allowOAuth2Auth" json:"allowOAuth2Auth"`
AllowUsernameAuth bool `form:"allowUsernameAuth" json:"allowUsernameAuth"`
AllowEmailAuth bool `form:"allowEmailAuth" json:"allowEmailAuth"`
RequireEmail bool `form:"requireEmail" json:"requireEmail"`
ExceptEmailDomains []string `form:"exceptEmailDomains" json:"exceptEmailDomains"`
OnlyEmailDomains []string `form:"onlyEmailDomains" json:"onlyEmailDomains"`
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
}
// Validate implements [validation.Validatable] interface.
func (o CollectionAuthOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(&o.ManageRule, validation.NilOrNotEmpty),
validation.Field(
&o.ExceptEmailDomains,
validation.When(len(o.OnlyEmailDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
validation.Field(
&o.OnlyEmailDomains,
validation.When(len(o.ExceptEmailDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
validation.Field(
&o.MinPasswordLength,
validation.When(o.AllowUsernameAuth || o.AllowEmailAuth, validation.Required),
validation.Min(5),
validation.Max(72),
),
)
}
+371
View File
@@ -1,9 +1,13 @@
package models_test
import (
"encoding/json"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestCollectionTableName(t *testing.T) {
@@ -23,3 +27,370 @@ func TestCollectionBaseFilesPath(t *testing.T) {
t.Fatalf("Expected path %s, got %s", expected, m.BaseFilesPath())
}
}
func TestCollectionIsBase(t *testing.T) {
scenarios := []struct {
collection models.Collection
expected bool
}{
{models.Collection{}, false},
{models.Collection{Type: "unknown"}, false},
{models.Collection{Type: models.CollectionTypeBase}, true},
{models.Collection{Type: models.CollectionTypeAuth}, false},
}
for i, s := range scenarios {
result := s.collection.IsBase()
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
func TestCollectionIsAuth(t *testing.T) {
scenarios := []struct {
collection models.Collection
expected bool
}{
{models.Collection{}, false},
{models.Collection{Type: "unknown"}, false},
{models.Collection{Type: models.CollectionTypeBase}, false},
{models.Collection{Type: models.CollectionTypeAuth}, true},
}
for i, s := range scenarios {
result := s.collection.IsAuth()
if result != s.expected {
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
}
}
}
func TestCollectionMarshalJSON(t *testing.T) {
scenarios := []struct {
name string
collection models.Collection
expected string
}{
{
"no type",
models.Collection{Name: "test"},
`{"id":"","created":"","updated":"","name":"test","type":"","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
},
{
"unknown type + non empty options",
models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
`{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
},
{
"base type + non empty options",
models.Collection{Name: "test", Type: models.CollectionTypeBase, ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
`{"id":"","created":"","updated":"","name":"test","type":"base","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
},
{
"auth type + non empty options",
models.Collection{BaseModel: models.BaseModel{Id: "test"}, Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "allowOAuth2Auth": true, "minPasswordLength": 4}},
`{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
},
}
for _, s := range scenarios {
result, err := s.collection.MarshalJSON()
if err != nil {
t.Errorf("(%s) Unexpected error %v", s.name, err)
continue
}
if string(result) != s.expected {
t.Errorf("(%s) Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
}
}
}
func TestCollectionBaseOptions(t *testing.T) {
scenarios := []struct {
name string
collection models.Collection
expected string
}{
{
"no type",
models.Collection{Options: types.JsonMap{"test": 123}},
"{}",
},
{
"unknown type",
models.Collection{Type: "anything", Options: types.JsonMap{"test": 123}},
"{}",
},
{
"different type",
models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
"{}",
},
{
"base type",
models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123}},
"{}",
},
}
for _, s := range scenarios {
result := s.collection.BaseOptions()
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
func TestCollectionAuthOptions(t *testing.T) {
options := types.JsonMap{"test": 123, "minPasswordLength": 4}
expectedSerialization := `{"manageRule":null,"allowOAuth2Auth":false,"allowUsernameAuth":false,"allowEmailAuth":false,"requireEmail":false,"exceptEmailDomains":null,"onlyEmailDomains":null,"minPasswordLength":4}`
scenarios := []struct {
name string
collection models.Collection
expected string
}{
{
"no type",
models.Collection{Options: options},
expectedSerialization,
},
{
"unknown type",
models.Collection{Type: "anything", Options: options},
expectedSerialization,
},
{
"different type",
models.Collection{Type: models.CollectionTypeBase, Options: options},
expectedSerialization,
},
{
"auth type",
models.Collection{Type: models.CollectionTypeAuth, Options: options},
expectedSerialization,
},
}
for _, s := range scenarios {
result := s.collection.AuthOptions()
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
func TestNormalizeOptions(t *testing.T) {
scenarios := []struct {
name string
collection models.Collection
expected string // serialized options
}{
{
"unknown type",
models.Collection{Type: "unknown", Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
"{}",
},
{
"base type",
models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
"{}",
},
{
"auth type",
models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
`{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
},
}
for _, s := range scenarios {
if err := s.collection.NormalizeOptions(); err != nil {
t.Errorf("(%s) Unexpected error %v", s.name, err)
continue
}
encoded, err := json.Marshal(s.collection.Options)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
func TestDecodeOptions(t *testing.T) {
m := models.Collection{
Options: types.JsonMap{"test": 123},
}
result := struct {
Test int
}{}
if err := m.DecodeOptions(&result); err != nil {
t.Fatal(err)
}
if result.Test != 123 {
t.Fatalf("Expected %v, got %v", 123, result.Test)
}
}
func TestSetOptions(t *testing.T) {
scenarios := []struct {
name string
collection models.Collection
options any
expected string // serialized options
}{
{
"no type",
models.Collection{},
map[string]any{},
"{}",
},
{
"unknown type + non empty options",
models.Collection{Type: "unknown", Options: types.JsonMap{"test": 123}},
map[string]any{"test": 456, "minPasswordLength": 4},
"{}",
},
{
"base type",
models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123}},
map[string]any{"test": 456, "minPasswordLength": 4},
"{}",
},
{
"auth type",
models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123}},
map[string]any{"test": 456, "minPasswordLength": 4},
`{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
},
}
for _, s := range scenarios {
if err := s.collection.SetOptions(s.options); err != nil {
t.Errorf("(%s) Unexpected error %v", s.name, err)
continue
}
encoded, err := json.Marshal(s.collection.Options)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
func TestCollectionBaseOptionsValidate(t *testing.T) {
opt := models.CollectionBaseOptions{}
if err := opt.Validate(); err != nil {
t.Fatal(err)
}
}
func TestCollectionAuthOptionsValidate(t *testing.T) {
scenarios := []struct {
name string
options models.CollectionAuthOptions
expectedErrors []string
}{
{
"empty",
models.CollectionAuthOptions{},
nil,
},
{
"empty string ManageRule",
models.CollectionAuthOptions{ManageRule: types.Pointer("")},
[]string{"manageRule"},
},
{
"minPasswordLength < 5",
models.CollectionAuthOptions{MinPasswordLength: 3},
[]string{"minPasswordLength"},
},
{
"minPasswordLength > 72",
models.CollectionAuthOptions{MinPasswordLength: 73},
[]string{"minPasswordLength"},
},
{
"both OnlyDomains and ExceptDomains set",
models.CollectionAuthOptions{
OnlyEmailDomains: []string{"example.com", "test.com"},
ExceptEmailDomains: []string{"example.com", "test.com"},
},
[]string{"onlyEmailDomains", "exceptEmailDomains"},
},
{
"only OnlyDomains set",
models.CollectionAuthOptions{
OnlyEmailDomains: []string{"example.com", "test.com"},
},
[]string{},
},
{
"only ExceptEmailDomains set",
models.CollectionAuthOptions{
ExceptEmailDomains: []string{"example.com", "test.com"},
},
[]string{},
},
{
"all fields with valid data",
models.CollectionAuthOptions{
ManageRule: types.Pointer("test"),
AllowOAuth2Auth: true,
AllowUsernameAuth: true,
AllowEmailAuth: true,
RequireEmail: true,
ExceptEmailDomains: []string{"example.com", "test.com"},
OnlyEmailDomains: nil,
MinPasswordLength: 5,
},
[]string{},
},
}
for _, s := range scenarios {
result := s.options.Validate()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%s) Failed to parse errors %v", s.name, result)
continue
}
if len(errs) != len(s.expectedErrors) {
t.Errorf("(%s) Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
continue
}
for key := range errs {
if !list.ExistInSlice(key, s.expectedErrors) {
t.Errorf("(%s) Unexpected error key %q in \n%v", s.name, key, errs)
}
}
}
}
+4 -3
View File
@@ -5,9 +5,10 @@ var _ Model = (*ExternalAuth)(nil)
type ExternalAuth struct {
BaseModel
UserId string `db:"userId" json:"userId"`
Provider string `db:"provider" json:"provider"`
ProviderId string `db:"providerId" json:"providerId"`
CollectionId string `db:"collectionId" json:"collectionId"`
RecordId string `db:"recordId" json:"recordId"`
Provider string `db:"provider" json:"provider"`
ProviderId string `db:"providerId" json:"providerId"`
}
func (m *ExternalAuth) TableName() string {
+441 -117
View File
@@ -2,28 +2,34 @@ package models
import (
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/crypto/bcrypt"
)
var _ Model = (*Record)(nil)
var _ ColumnValueMapper = (*Record)(nil)
var _ FilesManager = (*Record)(nil)
var (
_ Model = (*Record)(nil)
_ ColumnValueMapper = (*Record)(nil)
_ FilesManager = (*Record)(nil)
)
type Record struct {
BaseModel
collection *Collection
data map[string]any
expand map[string]any
exportUnknown bool // whether to export unknown fields
ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
data map[string]any // any custom data in addition to the base model fields
expand map[string]any // expanded relations
}
// NewRecord initializes a new empty Record model.
@@ -34,34 +40,43 @@ func NewRecord(collection *Collection) *Record {
}
}
// nullStringMapValue returns the raw string value if it exist and
// its not NULL, otherwise - nil.
func nullStringMapValue(data dbx.NullStringMap, key string) any {
nullString, ok := data[key]
if ok && nullString.Valid {
return nullString.String
}
return nil
}
// NewRecordFromNullStringMap initializes a single new Record model
// with data loaded from the provided NullStringMap.
func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
resultMap := map[string]any{}
// load schema fields
for _, field := range collection.Schema.Fields() {
var rawValue any
resultMap[field.Name] = nullStringMapValue(data, field.Name)
}
nullString, ok := data[field.Name]
if !ok || !nullString.Valid {
rawValue = nil
} else {
rawValue = nullString.String
// load base model fields
for _, name := range schema.BaseModelFieldNames() {
resultMap[name] = nullStringMapValue(data, name)
}
// load auth fields
if collection.IsAuth() {
for _, name := range schema.AuthFieldNames() {
resultMap[name] = nullStringMapValue(data, name)
}
resultMap[field.Name] = rawValue
}
record := NewRecord(collection)
// load base mode fields
resultMap[schema.ReservedFieldNameId] = data[schema.ReservedFieldNameId].String
resultMap[schema.ReservedFieldNameCreated] = data[schema.ReservedFieldNameCreated].String
resultMap[schema.ReservedFieldNameUpdated] = data[schema.ReservedFieldNameUpdated].String
if err := record.Load(resultMap); err != nil {
log.Println("Failed to unmarshal record:", err)
}
record.Load(resultMap)
return record
}
@@ -88,77 +103,150 @@ func (m *Record) Collection() *Collection {
return m.collection
}
// GetExpand returns a shallow copy of the optional `expand` data
// Expand returns a shallow copy of the record.expand data
// attached to the current Record model.
func (m *Record) GetExpand() map[string]any {
func (m *Record) Expand() map[string]any {
return shallowCopy(m.expand)
}
// SetExpand assigns the provided data to `record.expand`.
func (m *Record) SetExpand(data map[string]any) {
m.expand = shallowCopy(data)
// SetExpand assigns the provided data to record.expand.
func (m *Record) SetExpand(expand map[string]any) {
m.expand = shallowCopy(expand)
}
// Data returns a shallow copy of the currently loaded record's data.
func (m *Record) Data() map[string]any {
return shallowCopy(m.data)
}
// SchemaData returns a shallow copy ONLY of the defined record schema fields data.
func (m *Record) SchemaData() map[string]any {
result := map[string]any{}
// SetDataValue sets the provided key-value data pair for the current Record model.
//
// This method does nothing if the record doesn't have a `key` field.
func (m *Record) SetDataValue(key string, value any) {
if m.data == nil {
m.data = map[string]any{}
for _, field := range m.collection.Schema.Fields() {
if v, ok := m.data[field.Name]; ok {
result[field.Name] = v
}
}
field := m.Collection().Schema.GetFieldByName(key)
if field != nil {
m.data[key] = field.PrepareValue(value)
return result
}
// UnknownData returns a shallow copy ONLY of the unknown record fields data,
// aka. fields that are neither one of the base and special system ones,
// nor defined by the collection schema.
func (m *Record) UnknownData() map[string]any {
return m.extractUnknownData(m.data)
}
// IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check.
func (m *Record) IgnoreEmailVisibility(state bool) {
m.ignoreEmailVisibility = state
}
// WithUnkownData toggles the export/serialization of unknown data fields
// (false by default).
func (m *Record) WithUnkownData(state bool) {
m.exportUnknown = state
}
// Set sets the provided key-value data pair for the current Record model.
//
// If the record collection has field with name matching the provided "key",
// the value will be further normalized according to the field rules.
func (m *Record) Set(key string, value any) {
switch key {
case schema.FieldNameId:
m.Id = cast.ToString(value)
case schema.FieldNameCreated:
m.Created, _ = types.ParseDateTime(value)
case schema.FieldNameUpdated:
m.Updated, _ = types.ParseDateTime(value)
case schema.FieldNameExpand:
m.SetExpand(cast.ToStringMap(value))
default:
var v = value
if field := m.Collection().Schema.GetFieldByName(key); field != nil {
v = field.PrepareValue(value)
} else if m.collection.IsAuth() {
// normalize auth fields
switch key {
case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
v = cast.ToBool(value)
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
v, _ = types.ParseDateTime(value)
case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
v = cast.ToString(value)
}
}
if m.data == nil {
m.data = map[string]any{}
}
m.data[key] = v
}
}
// GetDataValue returns the current record's data value for `key`.
//
// Returns nil if data value with `key` is not found or set.
func (m *Record) GetDataValue(key string) any {
return m.data[key]
// Get returns a single record model data value for "key".
func (m *Record) Get(key string) any {
switch key {
case schema.FieldNameId:
return m.Id
case schema.FieldNameCreated:
return m.Created
case schema.FieldNameUpdated:
return m.Updated
default:
if v, ok := m.data[key]; ok {
return v
}
return nil
}
}
// GetBoolDataValue returns the data value for `key` as a bool.
func (m *Record) GetBoolDataValue(key string) bool {
return cast.ToBool(m.GetDataValue(key))
// GetBool returns the data value for "key" as a bool.
func (m *Record) GetBool(key string) bool {
return cast.ToBool(m.Get(key))
}
// GetStringDataValue returns the data value for `key` as a string.
func (m *Record) GetStringDataValue(key string) string {
return cast.ToString(m.GetDataValue(key))
// GetString returns the data value for "key" as a string.
func (m *Record) GetString(key string) string {
return cast.ToString(m.Get(key))
}
// GetIntDataValue returns the data value for `key` as an int.
func (m *Record) GetIntDataValue(key string) int {
return cast.ToInt(m.GetDataValue(key))
// GetInt returns the data value for "key" as an int.
func (m *Record) GetInt(key string) int {
return cast.ToInt(m.Get(key))
}
// GetFloatDataValue returns the data value for `key` as a float64.
func (m *Record) GetFloatDataValue(key string) float64 {
return cast.ToFloat64(m.GetDataValue(key))
// GetFloat returns the data value for "key" as a float64.
func (m *Record) GetFloat(key string) float64 {
return cast.ToFloat64(m.Get(key))
}
// GetTimeDataValue returns the data value for `key` as a [time.Time] instance.
func (m *Record) GetTimeDataValue(key string) time.Time {
return cast.ToTime(m.GetDataValue(key))
// GetTime returns the data value for "key" as a [time.Time] instance.
func (m *Record) GetTime(key string) time.Time {
return cast.ToTime(m.Get(key))
}
// GetDateTimeDataValue returns the data value for `key` as a DateTime instance.
func (m *Record) GetDateTimeDataValue(key string) types.DateTime {
d, _ := types.ParseDateTime(m.GetDataValue(key))
// GetDateTime returns the data value for "key" as a DateTime instance.
func (m *Record) GetDateTime(key string) types.DateTime {
d, _ := types.ParseDateTime(m.Get(key))
return d
}
// GetStringSliceDataValue returns the data value for `key` as a slice of unique strings.
func (m *Record) GetStringSliceDataValue(key string) []string {
return list.ToUniqueStringSlice(m.GetDataValue(key))
// GetStringSlice returns the data value for "key" as a slice of unique strings.
func (m *Record) GetStringSlice(key string) []string {
return list.ToUniqueStringSlice(m.Get(key))
}
// Retrieves the "key" json field value and unmarshals it into "result".
//
// Example
// result := struct {
// FirstName string `json:"first_name"`
// }{}
// err := m.UnmarshalJSONField("my_field_name", &result)
func (m *Record) UnmarshalJSONField(key string, result any) error {
return json.Unmarshal([]byte(m.GetString(key)), &result)
}
// BaseFilesPath returns the storage dir path used by the record.
@@ -171,7 +259,7 @@ func (m *Record) BaseFilesPath() string {
func (m *Record) FindFileFieldByFile(filename string) *schema.SchemaField {
for _, field := range m.Collection().Schema.Fields() {
if field.Type == schema.FieldTypeFile {
names := m.GetStringSliceDataValue(field.Name)
names := m.GetStringSlice(field.Name)
if list.ExistInSlice(filename, names) {
return field
}
@@ -181,63 +269,76 @@ func (m *Record) FindFileFieldByFile(filename string) *schema.SchemaField {
}
// Load bulk loads the provided data into the current Record model.
func (m *Record) Load(data map[string]any) error {
if data[schema.ReservedFieldNameId] != nil {
id, err := cast.ToStringE(data[schema.ReservedFieldNameId])
if err != nil {
return err
}
m.Id = id
}
if data[schema.ReservedFieldNameCreated] != nil {
m.Created, _ = types.ParseDateTime(data[schema.ReservedFieldNameCreated])
}
if data[schema.ReservedFieldNameUpdated] != nil {
m.Updated, _ = types.ParseDateTime(data[schema.ReservedFieldNameUpdated])
}
func (m *Record) Load(data map[string]any) {
for k, v := range data {
m.SetDataValue(k, v)
m.Set(k, v)
}
return nil
}
// ColumnValueMap implements [ColumnValueMapper] interface.
func (m *Record) ColumnValueMap() map[string]any {
result := map[string]any{}
for key := range m.data {
result[key] = m.normalizeDataValueForDB(key)
// export schema field values
for _, field := range m.collection.Schema.Fields() {
result[field.Name] = m.getNormalizeDataValueForDB(field.Name)
}
// set base model fields
result[schema.ReservedFieldNameId] = m.Id
result[schema.ReservedFieldNameCreated] = m.Created
result[schema.ReservedFieldNameUpdated] = m.Updated
// export auth collection fields
if m.collection.IsAuth() {
for _, name := range schema.AuthFieldNames() {
result[name] = m.getNormalizeDataValueForDB(name)
}
}
// export base model fields
result[schema.FieldNameId] = m.getNormalizeDataValueForDB(schema.FieldNameId)
result[schema.FieldNameCreated] = m.getNormalizeDataValueForDB(schema.FieldNameCreated)
result[schema.FieldNameUpdated] = m.getNormalizeDataValueForDB(schema.FieldNameUpdated)
return result
}
// PublicExport exports only the record fields that are safe to be public.
//
// This method also skips the "hidden" fields, aka. fields prefixed with `#`.
// Fields marked as hidden will be exported only if `m.IgnoreEmailVisibility(true)` is set.
func (m *Record) PublicExport() map[string]any {
result := skipHiddenFields(m.data)
result := map[string]any{}
// set base model fields
result[schema.ReservedFieldNameId] = m.Id
result[schema.ReservedFieldNameCreated] = m.Created
result[schema.ReservedFieldNameUpdated] = m.Updated
// export unknown data fields if allowed
if m.exportUnknown {
for k, v := range m.UnknownData() {
result[k] = v
}
}
// add helper collection fields
result["@collectionId"] = m.collection.Id
result["@collectionName"] = m.collection.Name
// export schema field values
for _, field := range m.collection.Schema.Fields() {
result[field.Name] = m.Get(field.Name)
}
// export some of the safe auth collection fields
if m.collection.IsAuth() {
result[schema.FieldNameVerified] = m.Verified()
result[schema.FieldNameUsername] = m.Username()
result[schema.FieldNameEmailVisibility] = m.EmailVisibility()
if m.ignoreEmailVisibility || m.EmailVisibility() {
result[schema.FieldNameEmail] = m.Email()
}
}
// export base model fields
result[schema.FieldNameId] = m.GetId()
result[schema.FieldNameCreated] = m.GetCreated()
result[schema.FieldNameUpdated] = m.GetUpdated()
// add helper collection reference fields
result[schema.FieldNameCollectionId] = m.collection.Id
result[schema.FieldNameCollectionName] = m.collection.Name
// add expand (if set)
if m.expand != nil {
result["@expand"] = m.expand
result[schema.FieldNameExpand] = m.expand
}
return result
@@ -258,19 +359,41 @@ func (m *Record) UnmarshalJSON(data []byte) error {
return err
}
return m.Load(result)
m.Load(result)
return nil
}
// normalizeDataValueForDB returns the `key` data value formatted for db storage.
func (m *Record) normalizeDataValueForDB(key string) any {
val := m.GetDataValue(key)
// getNormalizeDataValueForDB returns the "key" data value formatted for db storage.
func (m *Record) getNormalizeDataValueForDB(key string) any {
var val any
// normalize auth fields
if m.collection.IsAuth() {
switch key {
case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
return m.GetBool(key)
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
return m.GetDateTime(key)
case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
return m.GetString(key)
}
}
val = m.Get(key)
switch ids := val.(type) {
case []string:
// encode strings slice
// encode string slice
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
case []int:
// encode int slice
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
case []float64:
// encode float64 slice
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
case []any:
// encode interfaces slice
// encode interface slice
return append(types.JsonArray{}, ids...)
default:
// no changes
@@ -289,17 +412,218 @@ func shallowCopy(data map[string]any) map[string]any {
return result
}
// skipHiddenFields returns a new data map without the "#" prefixed fields.
func skipHiddenFields(data map[string]any) map[string]any {
func (m *Record) extractUnknownData(data map[string]any) map[string]any {
knownFields := map[string]struct{}{}
for _, name := range schema.SystemFieldNames() {
knownFields[name] = struct{}{}
}
for _, name := range schema.BaseModelFieldNames() {
knownFields[name] = struct{}{}
}
for _, f := range m.collection.Schema.Fields() {
knownFields[f.Name] = struct{}{}
}
if m.collection.IsAuth() {
for _, name := range schema.AuthFieldNames() {
knownFields[name] = struct{}{}
}
}
result := map[string]any{}
for key, val := range data {
// ignore "#" prefixed fields
if strings.HasPrefix(key, "#") {
continue
for k, v := range m.data {
if _, ok := knownFields[k]; !ok {
result[k] = v
}
result[key] = val
}
return result
}
// -------------------------------------------------------------------
// Auth helpers
// -------------------------------------------------------------------
var notAuthRecordErr = errors.New("Not an auth collection record.")
// Username returns the "username" auth record data value.
func (m *Record) Username() string {
return m.GetString(schema.FieldNameUsername)
}
// SetUsername sets the "username" auth record data value.
//
// This method doesn't check whether the provided value is a valid username.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetUsername(username string) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameUsername, username)
return nil
}
// Email returns the "email" auth record data value.
func (m *Record) Email() string {
return m.GetString(schema.FieldNameEmail)
}
// SetEmail sets the "email" auth record data value.
//
// This method doesn't check whether the provided value is a valid email.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetEmail(email string) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameEmail, email)
return nil
}
// Verified returns the "emailVisibility" auth record data value.
func (m *Record) EmailVisibility() bool {
return m.GetBool(schema.FieldNameEmailVisibility)
}
// SetEmailVisibility sets the "emailVisibility" auth record data value.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetEmailVisibility(visible bool) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameEmailVisibility, visible)
return nil
}
// Verified returns the "verified" auth record data value.
func (m *Record) Verified() bool {
return m.GetBool(schema.FieldNameVerified)
}
// SetVerified sets the "verified" auth record data value.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetVerified(verified bool) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameVerified, verified)
return nil
}
// TokenKey returns the "tokenKey" auth record data value.
func (m *Record) TokenKey() string {
return m.GetString(schema.FieldNameTokenKey)
}
// SetTokenKey sets the "tokenKey" auth record data value.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetTokenKey(key string) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameTokenKey, key)
return nil
}
// RefreshTokenKey generates and sets new random auth record "tokenKey".
//
// Returns an error if the record is not from an auth collection.
func (m *Record) RefreshTokenKey() error {
return m.SetTokenKey(security.RandomString(50))
}
// LastResetSentAt returns the "lastResentSentAt" auth record data value.
func (m *Record) LastResetSentAt() types.DateTime {
return m.GetDateTime(schema.FieldNameLastResetSentAt)
}
// SetLastResetSentAt sets the "lastResentSentAt" auth record data value.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetLastResetSentAt(dateTime types.DateTime) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameLastResetSentAt, dateTime)
return nil
}
// LastVerificationSentAt returns the "lastVerificationSentAt" auth record data value.
func (m *Record) LastVerificationSentAt() types.DateTime {
return m.GetDateTime(schema.FieldNameLastVerificationSentAt)
}
// SetLastVerificationSentAt sets an "lastVerificationSentAt" auth record data value.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameLastVerificationSentAt, dateTime)
return nil
}
// ValidatePassword validates a plain password against the auth record password.
//
// Returns false if the password is incorrect or record is not from an auth collection.
func (m *Record) ValidatePassword(password string) bool {
if !m.collection.IsAuth() {
return false
}
err := bcrypt.CompareHashAndPassword(
[]byte(m.GetString(schema.FieldNamePasswordHash)),
[]byte(password),
)
return err == nil
}
// SetPassword sets cryptographically secure string to the auth record "password" field.
// This method also resets the "lastResetSentAt" and the "tokenKey" fields.
//
// Returns an error if the record is not from an auth collection or
// an empty password is provided.
func (m *Record) SetPassword(password string) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
if password == "" {
return errors.New("The provided plain password is empty")
}
// hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
if err != nil {
return err
}
m.Set(schema.FieldNamePasswordHash, string(hashedPassword))
m.Set(schema.FieldNameLastResetSentAt, types.DateTime{})
// invalidate previously issued tokens
return m.RefreshTokenKey()
}
+1104 -284
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -6,9 +6,9 @@ var _ Model = (*Request)(nil)
// list with the supported values for `Request.Auth`
const (
RequestAuthGuest = "guest"
RequestAuthUser = "user"
RequestAuthAdmin = "admin"
RequestAuthGuest = "guest"
RequestAuthAdmin = "admin"
RequestAuthRecord = "auth_record"
)
type Request struct {
+67 -35
View File
@@ -13,21 +13,55 @@ import (
"github.com/spf13/cast"
)
var schemaFieldNameRegex = regexp.MustCompile(`^\#?\w+$`)
var schemaFieldNameRegex = regexp.MustCompile(`^\w+$`)
// reserved internal field names
// commonly used field names
const (
ReservedFieldNameId = "id"
ReservedFieldNameCreated = "created"
ReservedFieldNameUpdated = "updated"
FieldNameId = "id"
FieldNameCreated = "created"
FieldNameUpdated = "updated"
FieldNameCollectionId = "collectionId"
FieldNameCollectionName = "collectionName"
FieldNameExpand = "expand"
FieldNameUsername = "username"
FieldNameEmail = "email"
FieldNameEmailVisibility = "emailVisibility"
FieldNameVerified = "verified"
FieldNameTokenKey = "tokenKey"
FieldNamePasswordHash = "passwordHash"
FieldNameLastResetSentAt = "lastResetSentAt"
FieldNameLastVerificationSentAt = "lastVerificationSentAt"
)
// ReservedFieldNames returns slice with reserved/system field names.
func ReservedFieldNames() []string {
// BaseModelFieldNames returns the field names that all models have (id, created, updated).
func BaseModelFieldNames() []string {
return []string{
ReservedFieldNameId,
ReservedFieldNameCreated,
ReservedFieldNameUpdated,
FieldNameId,
FieldNameCreated,
FieldNameUpdated,
}
}
// SystemFields returns special internal field names that are usually readonly.
func SystemFieldNames() []string {
return []string{
FieldNameCollectionId,
FieldNameCollectionName,
FieldNameExpand,
}
}
// AuthFieldNames returns the reserved "auth" collection auth field names.
func AuthFieldNames() []string {
return []string{
FieldNameUsername,
FieldNameEmail,
FieldNameEmailVisibility,
FieldNameVerified,
FieldNameTokenKey,
FieldNamePasswordHash,
FieldNameLastResetSentAt,
FieldNameLastVerificationSentAt,
}
}
@@ -43,7 +77,9 @@ const (
FieldTypeJson string = "json"
FieldTypeFile string = "file"
FieldTypeRelation string = "relation"
FieldTypeUser string = "user"
// Deprecated: Will be removed in v0.9!
FieldTypeUser string = "user"
)
// FieldTypes returns slice with all supported field types.
@@ -59,7 +95,6 @@ func FieldTypes() []string {
FieldTypeJson,
FieldTypeFile,
FieldTypeRelation,
FieldTypeUser,
}
}
@@ -69,7 +104,6 @@ func ArraybleFieldTypes() []string {
FieldTypeSelect,
FieldTypeFile,
FieldTypeRelation,
FieldTypeUser,
}
}
@@ -90,7 +124,7 @@ func (f *SchemaField) ColDefinition() string {
case FieldTypeNumber:
return "REAL DEFAULT 0"
case FieldTypeBool:
return "Boolean DEFAULT FALSE"
return "BOOLEAN DEFAULT FALSE"
case FieldTypeJson:
return "JSON DEFAULT NULL"
default:
@@ -133,9 +167,11 @@ func (f SchemaField) Validate() error {
// init field options (if not already)
f.InitOptions()
// add commonly used filter literals to the exclude names list
excludeNames := ReservedFieldNames()
excludeNames := BaseModelFieldNames()
// exclude filter literals
excludeNames = append(excludeNames, "null", "true", "false")
// exclude system literals
excludeNames = append(excludeNames, SystemFieldNames()...)
return validation.ValidateStruct(&f,
validation.Field(&f.Options, validation.Required, validation.By(f.checkOptions)),
@@ -198,8 +234,11 @@ func (f *SchemaField) InitOptions() error {
options = &FileOptions{}
case FieldTypeRelation:
options = &RelationOptions{}
// Deprecated: Will be removed in v0.9!
case FieldTypeUser:
options = &UserOptions{}
default:
return errors.New("Missing or unknown field field type.")
}
@@ -259,19 +298,7 @@ func (f *SchemaField) PrepareValue(value any) any {
ids := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*RelationOptions)
if options.MaxSelect <= 1 {
if len(ids) > 0 {
return ids[0]
}
return ""
}
return ids
case FieldTypeUser:
ids := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*UserOptions)
if options.MaxSelect <= 1 {
if options.MaxSelect != nil && *options.MaxSelect <= 1 {
if len(ids) > 0 {
return ids[0]
}
@@ -426,13 +453,18 @@ type SelectOptions struct {
}
func (o SelectOptions) Validate() error {
max := len(o.Values)
if max == 0 {
max = 1
}
return validation.ValidateStruct(&o,
validation.Field(&o.Values, validation.Required),
validation.Field(
&o.MaxSelect,
validation.Required,
validation.Min(1),
validation.Max(len(o.Values)),
validation.Max(max),
),
)
}
@@ -469,27 +501,27 @@ func (o FileOptions) Validate() error {
// -------------------------------------------------------------------
type RelationOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
CollectionId string `form:"collectionId" json:"collectionId"`
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
}
func (o RelationOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
validation.Field(&o.CollectionId, validation.Required),
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(1)),
)
}
// -------------------------------------------------------------------
// Deprecated: Will be removed in v0.9!
type UserOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
}
// Deprecated: Will be removed in v0.9!
func (o UserOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
)
return nil
}
+116 -132
View File
@@ -11,27 +11,48 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestReservedFieldNames(t *testing.T) {
result := schema.ReservedFieldNames()
func TestBaseModelFieldNames(t *testing.T) {
result := schema.BaseModelFieldNames()
expected := 3
if len(result) != 3 {
t.Fatalf("Expected %d names, got %d (%v)", 3, len(result), result)
if len(result) != expected {
t.Fatalf("Expected %d field names, got %d (%v)", expected, len(result), result)
}
}
func TestSystemFieldNames(t *testing.T) {
result := schema.SystemFieldNames()
expected := 3
if len(result) != expected {
t.Fatalf("Expected %d field names, got %d (%v)", expected, len(result), result)
}
}
func TestAuthFieldNames(t *testing.T) {
result := schema.AuthFieldNames()
expected := 8
if len(result) != expected {
t.Fatalf("Expected %d auth field names, got %d (%v)", expected, len(result), result)
}
}
func TestFieldTypes(t *testing.T) {
result := schema.FieldTypes()
expected := 10
if len(result) != 11 {
t.Fatalf("Expected %d types, got %d (%v)", 3, len(result), result)
if len(result) != expected {
t.Fatalf("Expected %d types, got %d (%v)", expected, len(result), result)
}
}
func TestArraybleFieldTypes(t *testing.T) {
result := schema.ArraybleFieldTypes()
expected := 3
if len(result) != 4 {
t.Fatalf("Expected %d types, got %d (%v)", 3, len(result), result)
if len(result) != expected {
t.Fatalf("Expected %d arrayble types, got %d (%v)", expected, len(result), result)
}
}
@@ -50,7 +71,7 @@ func TestSchemaFieldColDefinition(t *testing.T) {
},
{
schema.SchemaField{Type: schema.FieldTypeBool, Name: "test"},
"Boolean DEFAULT FALSE",
"BOOLEAN DEFAULT FALSE",
},
{
schema.SchemaField{Type: schema.FieldTypeEmail, Name: "test"},
@@ -80,10 +101,6 @@ func TestSchemaFieldColDefinition(t *testing.T) {
schema.SchemaField{Type: schema.FieldTypeRelation, Name: "test"},
"TEXT DEFAULT ''",
},
{
schema.SchemaField{Type: schema.FieldTypeUser, Name: "test"},
"TEXT DEFAULT ''",
},
}
for i, s := range scenarios {
@@ -297,7 +314,7 @@ func TestSchemaFieldValidate(t *testing.T) {
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
Name: schema.ReservedFieldNameId,
Name: schema.FieldNameId,
},
[]string{"name"},
},
@@ -306,7 +323,7 @@ func TestSchemaFieldValidate(t *testing.T) {
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
Name: schema.ReservedFieldNameCreated,
Name: schema.FieldNameCreated,
},
[]string{"name"},
},
@@ -315,7 +332,34 @@ func TestSchemaFieldValidate(t *testing.T) {
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
Name: schema.ReservedFieldNameUpdated,
Name: schema.FieldNameUpdated,
},
[]string{"name"},
},
{
"reserved name (collectionId)",
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
Name: schema.FieldNameCollectionId,
},
[]string{"name"},
},
{
"reserved name (collectionName)",
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
Name: schema.FieldNameCollectionName,
},
[]string{"name"},
},
{
"reserved name (expand)",
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
Name: schema.FieldNameExpand,
},
[]string{"name"},
},
@@ -456,7 +500,7 @@ func TestSchemaFieldInitOptions(t *testing.T) {
{
schema.SchemaField{Type: schema.FieldTypeRelation},
false,
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"maxSelect":0,"collectionId":"","cascadeDelete":false}}`,
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"maxSelect":null,"collectionId":"","cascadeDelete":false}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeUser},
@@ -548,8 +592,9 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "test", `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000Z"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123Z"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123Z", `"2022-01-01 11:27:10.123Z"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, types.DateTime{}, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, time.Time{}, `""`},
@@ -697,13 +742,48 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// relation (single)
{schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "abc", `"abc"`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
{
schema.SchemaField{Type: schema.FieldTypeRelation},
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
},
nil,
`""`,
},
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
},
"",
`""`,
},
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
},
123,
`"123"`,
},
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
},
"abc",
`"abc"`,
},
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
},
"1ba88b4f-e9da-42f0-9764-9a55c953e724",
`"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
},
{
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
`"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
},
@@ -711,7 +791,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: 2},
Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
nil,
`[]`,
@@ -719,7 +799,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: 2},
Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
"",
`[]`,
@@ -727,7 +807,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: 2},
Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{},
`[]`,
@@ -735,7 +815,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: 2},
Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
123,
`["123"]`,
@@ -743,7 +823,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: 2},
Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{"", "abc"},
`["abc"]`,
@@ -752,7 +832,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
// no values validation
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: 2},
Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
`["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
@@ -761,77 +841,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
// duplicated values
schema.SchemaField{
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: 2},
},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724", "1ba88b4f-e9da-42f0-9764-9a55c953e724"},
`["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
},
// user (single)
{schema.SchemaField{Type: schema.FieldTypeUser}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeUser}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
{
schema.SchemaField{Type: schema.FieldTypeUser},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
`"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
},
// user (multiple)
{
schema.SchemaField{
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{MaxSelect: 2},
},
nil,
`[]`,
},
{
schema.SchemaField{
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{MaxSelect: 2},
},
"",
`[]`,
},
{
schema.SchemaField{
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{MaxSelect: 2},
},
[]string{},
`[]`,
},
{
schema.SchemaField{
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{MaxSelect: 2},
},
123,
`["123"]`,
},
{
schema.SchemaField{
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{MaxSelect: 2},
},
[]string{"", "abc"},
`["abc"]`,
},
{
// no values validation
schema.SchemaField{
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{MaxSelect: 2},
},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
`["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
},
{
// duplicated values
schema.SchemaField{
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{MaxSelect: 2},
Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724", "1ba88b4f-e9da-42f0-9764-9a55c953e724"},
`["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
@@ -1277,13 +1287,13 @@ func TestRelationOptionsValidate(t *testing.T) {
{
"empty",
schema.RelationOptions{},
[]string{"maxSelect", "collectionId"},
[]string{"collectionId"},
},
{
"empty CollectionId",
schema.RelationOptions{
CollectionId: "",
MaxSelect: 1,
MaxSelect: types.Pointer(1),
},
[]string{"collectionId"},
},
@@ -1291,7 +1301,7 @@ func TestRelationOptionsValidate(t *testing.T) {
"MaxSelect <= 0",
schema.RelationOptions{
CollectionId: "abc",
MaxSelect: 0,
MaxSelect: types.Pointer(0),
},
[]string{"maxSelect"},
},
@@ -1299,33 +1309,7 @@ func TestRelationOptionsValidate(t *testing.T) {
"MaxSelect > 0 && non-empty CollectionId",
schema.RelationOptions{
CollectionId: "abc",
MaxSelect: 1,
},
[]string{},
},
}
checkFieldOptionsScenarios(t, scenarios)
}
func TestUserOptionsValidate(t *testing.T) {
scenarios := []fieldOptionsScenario{
{
"empty",
schema.UserOptions{},
[]string{"maxSelect"},
},
{
"MaxSelect <= 0",
schema.UserOptions{
MaxSelect: 0,
},
[]string{"maxSelect"},
},
{
"MaxSelect > 0",
schema.UserOptions{
MaxSelect: 1,
MaxSelect: types.Pointer(1),
},
[]string{},
},
-47
View File
@@ -1,47 +0,0 @@
package models
import (
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
)
var _ Model = (*User)(nil)
const (
// ProfileCollectionName is the name of the system user profiles collection.
ProfileCollectionName = "profiles"
// ProfileCollectionUserFieldName is the name of the user field from the system user profiles collection.
ProfileCollectionUserFieldName = "userId"
)
type User struct {
BaseAccount
Verified bool `db:"verified" json:"verified"`
LastVerificationSentAt types.DateTime `db:"lastVerificationSentAt" json:"lastVerificationSentAt"`
// profile rel
Profile *Record `db:"-" json:"profile"`
}
func (m *User) TableName() string {
return "_users"
}
// AsMap returns the current user data as a plain map
// (including the profile relation, if loaded).
func (m *User) AsMap() (map[string]any, error) {
userBytes, err := json.Marshal(m)
if err != nil {
return nil, err
}
result := map[string]any{}
if err := json.Unmarshal(userBytes, &result); err != nil {
return nil, err
}
return result, nil
}
-43
View File
@@ -1,43 +0,0 @@
package models_test
import (
"encoding/json"
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestUserTableName(t *testing.T) {
m := models.User{}
if m.TableName() != "_users" {
t.Fatalf("Unexpected table name, got %q", m.TableName())
}
}
func TestUserAsMap(t *testing.T) {
date, _ := types.ParseDateTime("2022-01-01 01:12:23.456")
m := models.User{}
m.Id = "210a896c-1e32-4c94-ae06-90c25fcf6791"
m.Email = "test@example.com"
m.PasswordHash = "test"
m.LastResetSentAt = date
m.Updated = date
m.RefreshTokenKey()
result, err := m.AsMap()
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
expected := `{"created":"","email":"test@example.com","id":"210a896c-1e32-4c94-ae06-90c25fcf6791","lastResetSentAt":"2022-01-01 01:12:23.456","lastVerificationSentAt":"","profile":null,"updated":"2022-01-01 01:12:23.456","verified":false}`
if string(encoded) != expected {
t.Errorf("Expected %s, got %s", expected, string(encoded))
}
}