You've already forked pocketbase
mirror of
https://github.com/pocketbase/pocketbase.git
synced 2025-11-06 17:39:57 +02:00
initial public commit
This commit is contained in:
50
forms/admin_login.go
Normal file
50
forms/admin_login.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminLogin defines an admin email/pass login form.
|
||||
type AdminLogin struct {
|
||||
app core.App
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewAdminLogin creates new admin login form for the provided app.
|
||||
func NewAdminLogin(app core.App) *AdminLogin {
|
||||
return &AdminLogin{app: app}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.Email),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the admin form.
|
||||
// On success returns the authorized admin model.
|
||||
func (form *AdminLogin) Submit() (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if admin.ValidatePassword(form.Password) {
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("Invalid login credentials.")
|
||||
}
|
||||
80
forms/admin_login_test.go
Normal file
80
forms/admin_login_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminLoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewAdminLogin(app)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", true},
|
||||
{"", "123", true},
|
||||
{"test@example.com", "", true},
|
||||
{"test", "123", true},
|
||||
{"test@example.com", "123", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewAdminLogin(app)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", true},
|
||||
{"", "1234567890", true},
|
||||
{"test@example.com", "", true},
|
||||
{"test", "1234567890", true},
|
||||
{"missing@example.com", "1234567890", true},
|
||||
{"test@example.com", "123456789", true},
|
||||
{"test@example.com", "1234567890", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
|
||||
admin, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if !s.expectError && admin == nil {
|
||||
t.Errorf("(%d) Expected admin model to be returned, got nil", i)
|
||||
}
|
||||
|
||||
if admin != nil && admin.Email != s.email {
|
||||
t.Errorf("(%d) Expected admin with email %s to be returned, got %v", i, s.email, admin)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
forms/admin_password_reset_confirm.go
Normal file
76
forms/admin_password_reset_confirm.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminPasswordResetConfirm defines an admin password reset confirmation form.
|
||||
type AdminPasswordResetConfirm struct {
|
||||
app core.App
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetConfirm creates new admin password reset confirmation form.
|
||||
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
|
||||
return &AdminPasswordResetConfirm{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminPasswordResetConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(10, 100)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
admin, err := form.app.Dao().FindAdminByToken(
|
||||
v,
|
||||
form.app.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil || admin == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the admin password reset confirmation form.
|
||||
// On success returns the updated admin model associated to `form.Token`.
|
||||
func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.app.Dao().FindAdminByToken(
|
||||
form.Token,
|
||||
form.app.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := admin.SetPassword(form.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.app.Dao().SaveAdmin(admin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return admin, nil
|
||||
}
|
||||
120
forms/admin_password_reset_confirm_test.go
Normal file
120
forms/admin_password_reset_confirm_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewAdminPasswordResetConfirm(app)
|
||||
|
||||
scenarios := []struct {
|
||||
token string
|
||||
password string
|
||||
passwordConfirm string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", "", true},
|
||||
{"", "123", "", true},
|
||||
{"", "", "123", true},
|
||||
{"test", "", "", true},
|
||||
{"test", "123", "", true},
|
||||
{"test", "123", "123", true},
|
||||
{
|
||||
// expired
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Token = s.token
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewAdminPasswordResetConfirm(app)
|
||||
|
||||
scenarios := []struct {
|
||||
token string
|
||||
password string
|
||||
passwordConfirm string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", "", true},
|
||||
{"", "123", "", true},
|
||||
{"", "", "123", true},
|
||||
{"test", "", "", true},
|
||||
{"test", "123", "", true},
|
||||
{"test", "123", "123", true},
|
||||
{
|
||||
// expired
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Token = s.token
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
admin, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(s.token)
|
||||
tokenAdminId, _ := claims["id"]
|
||||
|
||||
if admin.Id != tokenAdminId {
|
||||
t.Errorf("(%d) Expected admin with id %s to be returned, got %v", i, tokenAdminId, admin)
|
||||
}
|
||||
|
||||
if !admin.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected the admin password to have been updated to %q", i, form.Password)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
forms/admin_password_reset_request.go
Normal file
70
forms/admin_password_reset_request.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// AdminPasswordResetRequest defines an admin password reset request form.
|
||||
type AdminPasswordResetRequest struct {
|
||||
app core.App
|
||||
resendThreshold float64
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetRequest creates new admin password reset request form.
|
||||
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
|
||||
return &AdminPasswordResetRequest{
|
||||
app: app,
|
||||
resendThreshold: 120, // 2 min
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// This method doesn't verify that admin with `form.Email` exists (this is done on Submit).
|
||||
func (form *AdminPasswordResetRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success sends a password reset email to the `form.Email` admin.
|
||||
func (form *AdminPasswordResetRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := admin.LastResetSentAt.Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
|
||||
return errors.New("You have already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendAdminPasswordReset(form.app, admin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.app.Dao().SaveAdmin(admin)
|
||||
}
|
||||
84
forms/admin_password_reset_request_test.go
Normal file
84
forms/admin_password_reset_request_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetRequestValidate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewAdminPasswordResetRequest(testApp)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", false}, // doesn't check for existing admin
|
||||
{"test@example.com", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewAdminPasswordResetRequest(testApp)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", true},
|
||||
{"test@example.com", false},
|
||||
{"test@example.com", true}, // already requested
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form.Email = s.email
|
||||
|
||||
adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email)
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
adminAfter, _ := testApp.Dao().FindAdminByEmail(s.email)
|
||||
|
||||
if !s.expectError && (adminBefore.LastResetSentAt == adminAfter.LastResetSentAt || adminAfter.LastResetSentAt.IsZero()) {
|
||||
t.Errorf("(%d) Expected admin.LastResetSentAt to change, got %q", i, adminAfter.LastResetSentAt)
|
||||
}
|
||||
|
||||
expectedMails := 1
|
||||
if s.expectError {
|
||||
expectedMails = 0
|
||||
}
|
||||
if testApp.TestMailer.TotalSend != expectedMails {
|
||||
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
forms/admin_upsert.go
Normal file
91
forms/admin_upsert.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminUpsert defines an admin upsert (create/update) form.
|
||||
type AdminUpsert struct {
|
||||
app core.App
|
||||
admin *models.Admin
|
||||
isCreate bool
|
||||
|
||||
Avatar int `form:"avatar" json:"avatar"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// NewAdminUpsert creates new upsert form for the provided admin model
|
||||
// (pass an empty admin model instance (`&models.Admin{}`) for create).
|
||||
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
|
||||
form := &AdminUpsert{
|
||||
app: app,
|
||||
admin: admin,
|
||||
isCreate: !admin.HasId(),
|
||||
}
|
||||
|
||||
// load defaults
|
||||
form.Avatar = admin.Avatar
|
||||
form.Email = admin.Email
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminUpsert) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Avatar,
|
||||
validation.Min(0),
|
||||
validation.Max(9),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.isCreate, validation.Required),
|
||||
validation.Length(10, 100),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
validation.When(form.Password != "", validation.Required),
|
||||
validation.By(validators.Compare(form.Password)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.app.Dao().IsAdminEmailUnique(v, form.admin.Id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validation.NewError("validation_admin_email_exists", "Admin email already exists.")
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form's admin model.
|
||||
func (form *AdminUpsert) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form.admin.Avatar = form.Avatar
|
||||
form.admin.Email = form.Email
|
||||
|
||||
if form.Password != "" {
|
||||
form.admin.SetPassword(form.Password)
|
||||
}
|
||||
|
||||
return form.app.Dao().SaveAdmin(form.admin)
|
||||
}
|
||||
285
forms/admin_upsert_test.go
Normal file
285
forms/admin_upsert_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestNewAdminUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
admin := &models.Admin{}
|
||||
admin.Avatar = 3
|
||||
admin.Email = "new@example.com"
|
||||
|
||||
form := forms.NewAdminUpsert(app, admin)
|
||||
|
||||
// test defaults
|
||||
if form.Avatar != admin.Avatar {
|
||||
t.Errorf("Expected Avatar %d, got %d", admin.Avatar, form.Avatar)
|
||||
}
|
||||
if form.Email != admin.Email {
|
||||
t.Errorf("Expected Email %q, got %q", admin.Email, form.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
avatar int
|
||||
email string
|
||||
password string
|
||||
passwordConfirm string
|
||||
expectedErrors int
|
||||
}{
|
||||
{
|
||||
"",
|
||||
-1,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
3,
|
||||
},
|
||||
{
|
||||
"",
|
||||
10,
|
||||
"invalid",
|
||||
"12345678",
|
||||
"87654321",
|
||||
4,
|
||||
},
|
||||
{
|
||||
// existing email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test2@example.com",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// mismatching passwords
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
"1234567891",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// create without setting password
|
||||
"",
|
||||
9,
|
||||
"test_create@example.com",
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// create with existing email
|
||||
"",
|
||||
9,
|
||||
"test@example.com",
|
||||
"1234567890!",
|
||||
"1234567890!",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// update without setting password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test_update@example.com",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
},
|
||||
{
|
||||
// create with password
|
||||
"",
|
||||
9,
|
||||
"test_create@example.com",
|
||||
"1234567890!",
|
||||
"1234567890!",
|
||||
0,
|
||||
},
|
||||
{
|
||||
// update with password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
4,
|
||||
"test_update@example.com",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
admin := &models.Admin{}
|
||||
if s.id != "" {
|
||||
admin, _ = app.Dao().FindAdminById(s.id)
|
||||
}
|
||||
|
||||
form := forms.NewAdminUpsert(app, admin)
|
||||
form.Avatar = s.avatar
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(errs) != s.expectedErrors {
|
||||
t.Errorf("(%d) Expected %d errors, got %d (%v)", i, s.expectedErrors, len(errs), errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpsertSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
// create empty
|
||||
"",
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
// update empty
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
// create failure - existing email
|
||||
"",
|
||||
`{
|
||||
"email": "test@example.com",
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890"
|
||||
}`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
// create failure - passwords mismatch
|
||||
"",
|
||||
`{
|
||||
"email": "test_new@example.com",
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567891"
|
||||
}`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
// create success
|
||||
"",
|
||||
`{
|
||||
"email": "test_new@example.com",
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890"
|
||||
}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
// update failure - existing email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
`{
|
||||
"email": "test2@example.com"
|
||||
}`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
// update failure - mismatching passwords
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567891"
|
||||
}`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
// update succcess - new email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
`{
|
||||
"email": "test_update@example.com"
|
||||
}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
// update succcess - new password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890"
|
||||
}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
isCreate := true
|
||||
admin := &models.Admin{}
|
||||
if s.id != "" {
|
||||
isCreate = false
|
||||
admin, _ = app.Dao().FindAdminById(s.id)
|
||||
}
|
||||
initialTokenKey := admin.TokenKey
|
||||
|
||||
form := forms.NewAdminUpsert(app, admin)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
foundAdmin, _ := app.Dao().FindAdminByEmail(form.Email)
|
||||
|
||||
if !s.expectError && isCreate && foundAdmin == nil {
|
||||
t.Errorf("(%d) Expected admin to be created, got nil", i)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue // skip persistence check
|
||||
}
|
||||
|
||||
if foundAdmin.Email != form.Email {
|
||||
t.Errorf("(%d) Expected email %s, got %s", i, form.Email, foundAdmin.Email)
|
||||
}
|
||||
|
||||
if foundAdmin.Avatar != form.Avatar {
|
||||
t.Errorf("(%d) Expected avatar %d, got %d", i, form.Avatar, foundAdmin.Avatar)
|
||||
}
|
||||
|
||||
if form.Password != "" && initialTokenKey == foundAdmin.TokenKey {
|
||||
t.Errorf("(%d) Expected token key to be renewed when setting a new password", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
215
forms/collection_upsert.go
Normal file
215
forms/collection_upsert.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
|
||||
|
||||
// CollectionUpsert defines a collection upsert (create/update) form.
|
||||
type CollectionUpsert struct {
|
||||
app core.App
|
||||
collection *models.Collection
|
||||
isCreate bool
|
||||
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
ListRule *string `form:"listRule" json:"listRule"`
|
||||
ViewRule *string `form:"viewRule" json:"viewRule"`
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
}
|
||||
|
||||
// NewCollectionUpsert creates new collection upsert form for the provided Collection model
|
||||
// (pass an empty Collection model instance (`&models.Collection{}`) for create).
|
||||
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
|
||||
form := &CollectionUpsert{
|
||||
app: app,
|
||||
collection: collection,
|
||||
isCreate: !collection.HasId(),
|
||||
}
|
||||
|
||||
// load defaults
|
||||
form.Name = collection.Name
|
||||
form.System = collection.System
|
||||
form.ListRule = collection.ListRule
|
||||
form.ViewRule = collection.ViewRule
|
||||
form.CreateRule = collection.CreateRule
|
||||
form.UpdateRule = collection.UpdateRule
|
||||
form.DeleteRule = collection.DeleteRule
|
||||
|
||||
clone, _ := collection.Schema.Clone()
|
||||
if clone != nil {
|
||||
form.Schema = *clone
|
||||
} else {
|
||||
form.Schema = schema.Schema{}
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *CollectionUpsert) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.System,
|
||||
validation.By(form.ensureNoSystemFlagChange),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Name,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
validation.Match(collectionNameRegex),
|
||||
validation.By(form.ensureNoSystemNameChange),
|
||||
validation.By(form.checkUniqueName),
|
||||
),
|
||||
// validates using the type's own validation rules + some collection's specific
|
||||
validation.Field(
|
||||
&form.Schema,
|
||||
validation.By(form.ensureNoSystemFieldsChange),
|
||||
validation.By(form.ensureNoFieldsTypeChange),
|
||||
validation.By(form.ensureNoFieldsNameReuse),
|
||||
),
|
||||
validation.Field(&form.ListRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.app.Dao().IsCollectionNameUnique(v, form.collection.Id) {
|
||||
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
|
||||
}
|
||||
|
||||
if (form.isCreate || strings.ToLower(v) != strings.ToLower(form.collection.Name)) && form.app.Dao().HasTable(v) {
|
||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.isCreate || !form.collection.System || v == form.collection.Name {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_name_change", "System collections cannot be renamed.")
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
|
||||
v, _ := value.(bool)
|
||||
|
||||
if form.isCreate || v == form.collection.System {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_flag_change", "System collection state cannot be changed.")
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
for _, field := range v.Fields() {
|
||||
oldField := form.collection.Schema.GetFieldById(field.Id)
|
||||
|
||||
if oldField != nil && oldField.Type != field.Type {
|
||||
return validation.NewError("validation_field_type_change", "Field type cannot be changed.")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
for _, oldField := range form.collection.Schema.Fields() {
|
||||
if !oldField.System {
|
||||
continue
|
||||
}
|
||||
|
||||
newField := v.GetFieldById(oldField.Id)
|
||||
|
||||
if newField == nil || oldField.String() != newField.String() {
|
||||
return validation.NewError("validation_system_field_change", "System fields cannot be deleted or changed.")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoFieldsNameReuse(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
for _, field := range v.Fields() {
|
||||
oldField := form.collection.Schema.GetFieldByName(field.Name)
|
||||
|
||||
if oldField != nil && oldField.Id != field.Id {
|
||||
return validation.NewError("validation_field_old_field_exist", "Cannot use existing schema field names when renaming fields.")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkRule(value any) error {
|
||||
v, _ := value.(*string)
|
||||
|
||||
if v == nil || *v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
dummy := &models.Collection{Schema: form.Schema}
|
||||
r := resolvers.NewRecordFieldResolver(form.app.Dao(), dummy, nil)
|
||||
|
||||
_, err := search.FilterData(*v).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_collection_rule", "Invalid filter rule.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form's Collection model.
|
||||
//
|
||||
// On success the related record table schema will be auto updated.
|
||||
func (form *CollectionUpsert) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// system flag can be set only for create
|
||||
if form.isCreate {
|
||||
form.collection.System = form.System
|
||||
}
|
||||
|
||||
// system collections cannot be renamed
|
||||
if form.isCreate || !form.collection.System {
|
||||
form.collection.Name = form.Name
|
||||
}
|
||||
|
||||
form.collection.Schema = form.Schema
|
||||
form.collection.ListRule = form.ListRule
|
||||
form.collection.ViewRule = form.ViewRule
|
||||
form.collection.CreateRule = form.CreateRule
|
||||
form.collection.UpdateRule = form.UpdateRule
|
||||
form.collection.DeleteRule = form.DeleteRule
|
||||
|
||||
return form.app.Dao().SaveCollection(form.collection)
|
||||
}
|
||||
452
forms/collection_upsert_test.go
Normal file
452
forms/collection_upsert_test.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func TestNewCollectionUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := &models.Collection{}
|
||||
collection.Name = "test"
|
||||
collection.System = true
|
||||
listRule := "testview"
|
||||
collection.ListRule = &listRule
|
||||
viewRule := "test_view"
|
||||
collection.ViewRule = &viewRule
|
||||
createRule := "test_create"
|
||||
collection.CreateRule = &createRule
|
||||
updateRule := "test_update"
|
||||
collection.UpdateRule = &updateRule
|
||||
deleteRule := "test_delete"
|
||||
collection.DeleteRule = &deleteRule
|
||||
collection.Schema = schema.NewSchema(&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
})
|
||||
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("Expected System %v, got %v", collection.System, form.System)
|
||||
}
|
||||
|
||||
if form.ListRule != collection.ListRule {
|
||||
t.Errorf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if form.ViewRule != collection.ViewRule {
|
||||
t.Errorf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if form.CreateRule != collection.CreateRule {
|
||||
t.Errorf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if form.UpdateRule != collection.UpdateRule {
|
||||
t.Errorf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if form.DeleteRule != collection.DeleteRule {
|
||||
t.Errorf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
// store previous state and modify the collection schema to verify
|
||||
// that the form.Schema is a deep clone
|
||||
loadedSchema, _ := collection.Schema.MarshalJSON()
|
||||
collection.Schema.AddField(&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
Type: schema.FieldTypeBool,
|
||||
})
|
||||
|
||||
formSchema, _ := form.Schema.MarshalJSON()
|
||||
|
||||
if string(formSchema) != string(loadedSchema) {
|
||||
t.Errorf("Expected Schema %v, got %v", string(loadedSchema), string(formSchema))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{"{}", []string{"name", "schema"}},
|
||||
{
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
],
|
||||
"listRule": "missing = '123'",
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
{
|
||||
`{
|
||||
"name": "test",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
],
|
||||
"listRule": "test='123'",
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, &models.Collection{})
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
existingName string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty create
|
||||
{"", "{}", []string{"name", "schema"}},
|
||||
// empty update
|
||||
{"demo", "{}", []string{}},
|
||||
// create failure
|
||||
{
|
||||
"",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
],
|
||||
"listRule": "missing = '123'",
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// create failure - existing name
|
||||
{
|
||||
"",
|
||||
`{
|
||||
"name": "demo",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
],
|
||||
"listRule": "test='123'",
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - existing internal table
|
||||
{
|
||||
"",
|
||||
`{
|
||||
"name": "_users",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - name starting with underscore
|
||||
{
|
||||
"",
|
||||
`{
|
||||
"name": "_test_new",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - duplicated field names (case insensitive)
|
||||
{
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"},
|
||||
{"name":"tESt","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// create success
|
||||
{
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test1","type":"text"},
|
||||
{"id":"b123456","name":"test2","type":"email"}
|
||||
],
|
||||
"listRule": "test1='123'",
|
||||
"viewRule": "test1='123'",
|
||||
"createRule": "test1='123'",
|
||||
"updateRule": "test1='123'",
|
||||
"deleteRule": "test1='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - changing field type
|
||||
{
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test1","type":"url"},
|
||||
{"id":"b123456","name":"test2","type":"bool"}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// update failure - rename fields to existing field names (aka. reusing field names)
|
||||
{
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test2","type":"text"},
|
||||
{"id":"b123456","name":"test1","type":"email"}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// update failure - existing name
|
||||
{
|
||||
"demo",
|
||||
`{"name": "demo2"}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// update failure - changing system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
`{
|
||||
"name": "update",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{"id":"koih1lqx","name":"userId","type":"text"}
|
||||
],
|
||||
"listRule": "userId = '123'",
|
||||
"viewRule": "userId = '123'",
|
||||
"createRule": "userId = '123'",
|
||||
"updateRule": "userId = '123'",
|
||||
"deleteRule": "userId = '123'"
|
||||
}`,
|
||||
[]string{"name", "system", "schema"},
|
||||
},
|
||||
// update failure - all fields
|
||||
{
|
||||
"demo",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
],
|
||||
"listRule": "missing = '123'",
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - update all fields
|
||||
{
|
||||
"demo",
|
||||
`{
|
||||
"name": "demo_update",
|
||||
"schema": [
|
||||
{"id":"_2hlxbmp","name":"test","type":"text"}
|
||||
],
|
||||
"listRule": "test='123'",
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - rename the schema field of the last updated collection
|
||||
// (fail due to filters old field references)
|
||||
{
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
{"id":"_2hlxbmp","name":"test_renamed","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - rename the schema field of the last updated collection
|
||||
// (cleared filter references)
|
||||
{
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
{"id":"_2hlxbmp","name":"test_renamed","type":"text"}
|
||||
],
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update success - system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
`{
|
||||
"listRule": "userId='123'",
|
||||
"viewRule": "userId='123'",
|
||||
"createRule": "userId='123'",
|
||||
"updateRule": "userId='123'",
|
||||
"deleteRule": "userId='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Errorf("(%d) Expected to find collection %q, got nil", i, form.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("(%d) Expected Name %q, got %q", i, collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("(%d) Expected System %v, got %v", i, collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Errorf("(%d) Expected ListRule %v, got %v", i, collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Errorf("(%d) Expected ViewRule %v, got %v", i, collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Errorf("(%d) Expected CreateRule %v, got %v", i, collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Errorf("(%d) Expected UpdateRule %v, got %v", i, collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Errorf("(%d) Expected DeleteRule %v, got %v", i, collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
formSchema, _ := form.Schema.MarshalJSON()
|
||||
collectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
if string(formSchema) != string(collectionSchema) {
|
||||
t.Errorf("(%d) Expected Schema %v, got %v", i, string(collectionSchema), string(formSchema))
|
||||
}
|
||||
}
|
||||
}
|
||||
23
forms/realtime_subscribe.go
Normal file
23
forms/realtime_subscribe.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
)
|
||||
|
||||
// RealtimeSubscribe defines a RealtimeSubscribe request form.
|
||||
type RealtimeSubscribe struct {
|
||||
ClientId string `form:"clientId" json:"clientId"`
|
||||
Subscriptions []string `form:"subscriptions" json:"subscriptions"`
|
||||
}
|
||||
|
||||
// NewRealtimeSubscribe creates new RealtimeSubscribe request form.
|
||||
func NewRealtimeSubscribe() *RealtimeSubscribe {
|
||||
return &RealtimeSubscribe{}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RealtimeSubscribe) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
31
forms/realtime_subscribe_test.go
Normal file
31
forms/realtime_subscribe_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
)
|
||||
|
||||
func TestRealtimeSubscribeValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
clientId string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{strings.Repeat("a", 256), true},
|
||||
{"test", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRealtimeSubscribe()
|
||||
form.ClientId = s.clientId
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
368
forms/record_upsert.go
Normal file
368
forms/record_upsert.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordUpsert defines a Record upsert form.
|
||||
type RecordUpsert struct {
|
||||
app core.App
|
||||
record *models.Record
|
||||
|
||||
isCreate bool
|
||||
filesToDelete []string // names list
|
||||
filesToUpload []*rest.UploadedFile
|
||||
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// NewRecordUpsert creates a new Record upsert form.
|
||||
// (pass a new Record model instance (`models.NewRecord(...)`) for create).
|
||||
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||
form := &RecordUpsert{
|
||||
app: app,
|
||||
record: record,
|
||||
isCreate: !record.HasId(),
|
||||
filesToDelete: []string{},
|
||||
filesToUpload: []*rest.UploadedFile{},
|
||||
}
|
||||
|
||||
form.Data = map[string]any{}
|
||||
for _, field := range record.Collection().Schema.Fields() {
|
||||
form.Data[field.Name] = record.GetDataValue(field.Name)
|
||||
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) getContentType(r *http.Request) string {
|
||||
t := r.Header.Get("Content-Type")
|
||||
for i, c := range t {
|
||||
if c == ' ' || c == ';' {
|
||||
return t[:i]
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractRequestData(r *http.Request) (map[string]any, error) {
|
||||
switch form.getContentType(r) {
|
||||
case "application/json":
|
||||
return form.extractJsonData(r)
|
||||
case "multipart/form-data":
|
||||
return form.extractMultipartFormData(r)
|
||||
default:
|
||||
return nil, errors.New("Unsupported request Content-Type.")
|
||||
}
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractJsonData(r *http.Request) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
|
||||
err := rest.ReadJsonBodyCopy(r, &result)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
|
||||
// parse form data (if not already)
|
||||
if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
arrayValueSupportTypes := schema.ArraybleFieldTypes()
|
||||
|
||||
for key, values := range r.PostForm {
|
||||
if len(values) == 0 {
|
||||
result[key] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) {
|
||||
result[key] = values
|
||||
} else {
|
||||
result[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) normalizeData() error {
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
if v, ok := form.Data[field.Name]; ok {
|
||||
form.Data[field.Name] = field.PrepareValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes json OR multipart/form-data request data.
|
||||
//
|
||||
// File upload is supported only via multipart/form-data.
|
||||
//
|
||||
// To REPLACE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index (eg. `myfile.0`) and set the new value.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// assign the file value to the field name (eg. `myfile`).
|
||||
//
|
||||
// To DELETE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index (eg. `myfile.0`) and set it to null or empty string.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// reset the field using its field name (eg. `myfile`).
|
||||
func (form *RecordUpsert) LoadData(r *http.Request) error {
|
||||
requestData, err := form.extractRequestData(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// extend base data with the extracted one
|
||||
extendedData := form.record.Data()
|
||||
rawData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(rawData, &extendedData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
key := field.Name
|
||||
value, _ := extendedData[key]
|
||||
value = field.PrepareValue(value)
|
||||
|
||||
if field.Type == schema.FieldTypeFile {
|
||||
options, _ := field.Options.(*schema.FileOptions)
|
||||
oldNames := list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
// delete previously uploaded file(s)
|
||||
if options.MaxSelect == 1 {
|
||||
// search for unset zero indexed key as a fallback
|
||||
indexedKeyValue, hasIndexedKey := extendedData[key+".0"]
|
||||
|
||||
if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") {
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = append(form.filesToDelete, oldNames...)
|
||||
}
|
||||
form.Data[key] = nil
|
||||
}
|
||||
} else if options.MaxSelect > 1 {
|
||||
// search for individual file index to delete (eg. "file.0")
|
||||
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
|
||||
indexesToDelete := []int{}
|
||||
for indexedKey := range extendedData {
|
||||
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
|
||||
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
|
||||
if indexErr != nil || index >= len(oldNames) {
|
||||
continue
|
||||
}
|
||||
indexesToDelete = append(indexesToDelete, index)
|
||||
}
|
||||
}
|
||||
|
||||
// slice to fill only with the non-deleted indexes
|
||||
nonDeleted := []string{}
|
||||
for i, name := range oldNames {
|
||||
// not marked for deletion
|
||||
if !list.ExistInSlice(i, indexesToDelete) {
|
||||
nonDeleted = append(nonDeleted, name)
|
||||
continue
|
||||
}
|
||||
|
||||
// store the id to actually delete the file later
|
||||
form.filesToDelete = append(form.filesToDelete, name)
|
||||
}
|
||||
form.Data[key] = nonDeleted
|
||||
}
|
||||
|
||||
// check if there are any new uploaded form files
|
||||
files, err := rest.FindUploadedFiles(r, key)
|
||||
if err != nil {
|
||||
continue // skip invalid or missing file(s)
|
||||
}
|
||||
|
||||
// refresh oldNames list
|
||||
oldNames = list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
// delete previous file(s) before replacing
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
}
|
||||
form.filesToUpload = append(form.filesToUpload, files[0])
|
||||
form.Data[key] = files[0].Name()
|
||||
} else if options.MaxSelect > 1 {
|
||||
// append the id of each uploaded file instance
|
||||
form.filesToUpload = append(form.filesToUpload, files...)
|
||||
for _, file := range files {
|
||||
oldNames = append(oldNames, file.Name())
|
||||
}
|
||||
form.Data[key] = oldNames
|
||||
}
|
||||
} else {
|
||||
form.Data[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return form.normalizeData()
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordUpsert) Validate() error {
|
||||
dataValidator := validators.NewRecordDataValidator(
|
||||
form.app.Dao(),
|
||||
form.record,
|
||||
form.filesToUpload,
|
||||
)
|
||||
|
||||
return dataValidator.Validate(form.Data)
|
||||
}
|
||||
|
||||
// DrySubmit performs a form submit within a transaction and reverts it.
|
||||
// For actual record persistence, check the `form.Submit()` method.
|
||||
//
|
||||
// This method doesn't handle file uploads/deletes or trigger any app events!
|
||||
func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// bulk load form data
|
||||
if err := form.record.Load(form.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
tx, ok := txDao.DB().(*dbx.Tx)
|
||||
if !ok {
|
||||
return errors.New("failed to get transaction db")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
txDao.BeforeCreateFunc = nil
|
||||
txDao.AfterCreateFunc = nil
|
||||
txDao.BeforeUpdateFunc = nil
|
||||
txDao.AfterUpdateFunc = nil
|
||||
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return callback(txDao)
|
||||
})
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form Record model.
|
||||
func (form *RecordUpsert) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// bulk load form data
|
||||
if err := form.record.Load(form.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// persist record model
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete old files (if any)
|
||||
if err := form.processFilesToDelete(); err != nil {
|
||||
// for now fail silently to avoid reupload when `form.Submit()`
|
||||
// is called manually (aka. not from an api request)...
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) processFilesToUpload() error {
|
||||
if len(form.filesToUpload) == 0 {
|
||||
return nil // nothing to upload
|
||||
}
|
||||
|
||||
if !form.record.HasId() {
|
||||
return errors.New("The record is not persisted yet.")
|
||||
}
|
||||
|
||||
fs, err := form.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
for i := len(form.filesToUpload) - 1; i >= 0; i-- {
|
||||
file := form.filesToUpload[i]
|
||||
path := form.record.BaseFilesPath() + "/" + file.Name()
|
||||
|
||||
if err := fs.Upload(file.Bytes(), path); err == nil {
|
||||
form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(form.filesToUpload) > 0 {
|
||||
return errors.New("Failed to upload all files.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) processFilesToDelete() error {
|
||||
if len(form.filesToDelete) == 0 {
|
||||
return nil // nothing to delete
|
||||
}
|
||||
|
||||
if !form.record.HasId() {
|
||||
return errors.New("The record is not persisted yet.")
|
||||
}
|
||||
|
||||
fs, err := form.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
for i := len(form.filesToDelete) - 1; i >= 0; i-- {
|
||||
filename := form.filesToDelete[i]
|
||||
path := form.record.BaseFilesPath() + "/" + filename
|
||||
|
||||
if err := fs.Delete(path); err == nil {
|
||||
form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...)
|
||||
}
|
||||
|
||||
// try to delete the related file thumbs (if any)
|
||||
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
|
||||
}
|
||||
|
||||
if len(form.filesToDelete) > 0 {
|
||||
return errors.New("Failed to delete all files.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
498
forms/record_upsert_test.go
Normal file
498
forms/record_upsert_test.go
Normal file
@@ -0,0 +1,498 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestNewRecordUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
record := models.NewRecord(collection)
|
||||
record.SetDataValue("title", "test_value")
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
|
||||
val, _ := form.Data["title"]
|
||||
if val != "test_value" {
|
||||
t.Errorf("Expected record data to be load, got %v", form.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertLoadDataUnsupported(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testData := "title=test123"
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(testData))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
|
||||
|
||||
if err := form.LoadData(req); err == nil {
|
||||
t.Fatal("Expected LoadData to fail, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertLoadDataJson(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testData := map[string]any{
|
||||
"title": "test123",
|
||||
"unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"onefile": nil,
|
||||
"manyfiles.0": "",
|
||||
"manyfiles.1": "test.png", // should be ignored
|
||||
"onlyimages": nil, // should be ignored
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
jsonBody, _ := json.Marshal(testData)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader(jsonBody))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
loadErr := form.LoadData(req)
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["title"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["unknown"]; ok {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
onefile, ok := form.Data["onefile"]
|
||||
if !ok {
|
||||
t.Fatal("Expect onefile field to be set")
|
||||
}
|
||||
if onefile != nil {
|
||||
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
|
||||
}
|
||||
|
||||
manyfiles, ok := form.Data["manyfiles"]
|
||||
if !ok || manyfiles == nil {
|
||||
t.Fatal("Expect manyfiles field to be set")
|
||||
}
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles))
|
||||
if manyfilesRemains != 1 {
|
||||
t.Fatalf("Expect only 1 manyfiles to remain, got %v", manyfiles)
|
||||
}
|
||||
|
||||
// cannot reset multiple file upload field with just using the field name
|
||||
onlyimages, ok := form.Data["onlyimages"]
|
||||
if !ok || onlyimages == nil {
|
||||
t.Fatal("Expect onlyimages field to be set and not be altered")
|
||||
}
|
||||
onlyimagesRemains := len(list.ToUniqueStringSlice(onlyimages))
|
||||
expectedRemains := 2 // 2 existing
|
||||
if onlyimagesRemains != expectedRemains {
|
||||
t.Fatalf("Expect onlyimages to be %d, got %d (%v)", expectedRemains, onlyimagesRemains, onlyimages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertLoadDataMultipart(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "test123",
|
||||
"unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"onefile": "",
|
||||
"manyfiles.0": "",
|
||||
"manyfiles.1": "test.png", // should be ignored
|
||||
"onlyimages": "", // should be ignored
|
||||
}, "onlyimages")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
loadErr := form.LoadData(req)
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["title"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["unknown"]; ok {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
onefile, ok := form.Data["onefile"]
|
||||
if !ok {
|
||||
t.Fatal("Expect onefile field to be set")
|
||||
}
|
||||
if onefile != nil {
|
||||
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
|
||||
}
|
||||
|
||||
manyfiles, ok := form.Data["manyfiles"]
|
||||
if !ok || manyfiles == nil {
|
||||
t.Fatal("Expect manyfiles field to be set")
|
||||
}
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles))
|
||||
if manyfilesRemains != 1 {
|
||||
t.Fatalf("Expect only 1 manyfiles to remain, got %v", manyfiles)
|
||||
}
|
||||
|
||||
onlyimages, ok := form.Data["onlyimages"]
|
||||
if !ok || onlyimages == nil {
|
||||
t.Fatal("Expect onlyimages field to be set and not be altered")
|
||||
}
|
||||
onlyimagesRemains := len(list.ToUniqueStringSlice(onlyimages))
|
||||
expectedRemains := 3 // 2 existing + 1 new upload
|
||||
if onlyimagesRemains != expectedRemains {
|
||||
t.Fatalf("Expect onlyimages to be %d, got %d (%v)", expectedRemains, onlyimagesRemains, onlyimages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertValidateFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// try with invalid test data to check whether the RecordDataValidator is triggered
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"unknown": "test456", // should be ignored
|
||||
"title": "a",
|
||||
"onerel": "00000000-84ab-4057-a592-4604a731f78f",
|
||||
}, "manyfiles", "manyfiles")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedErrors := []string{"title", "onerel", "manyfiles"}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
result := form.Validate()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", expectedErrors, errs)
|
||||
}
|
||||
for _, k := range expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertValidateSuccess(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"unknown": "test456", // should be ignored
|
||||
"title": "abc",
|
||||
"onerel": "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
|
||||
}, "manyfiles", "onefile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
result := form.Validate()
|
||||
if result != nil {
|
||||
t.Fatal(result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertDrySubmitFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "a",
|
||||
"onerel": "00000000-84ab-4057-a592-4604a731f78f",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
callbackCalls := 0
|
||||
|
||||
// ensure that validate is triggered
|
||||
// ---
|
||||
result := form.DrySubmit(func(txDao *daos.Dao) error {
|
||||
callbackCalls++
|
||||
return nil
|
||||
})
|
||||
if result == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
if callbackCalls != 0 {
|
||||
t.Fatalf("Expected callbackCalls to be 0, got %d", callbackCalls)
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") == "a" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a")
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("onerel") == "00000000-84ab-4057-a592-4604a731f78f" {
|
||||
t.Fatalf("Expected record.onerel to be %s, got %s", recordBefore.GetStringDataValue("onerel"), recordAfter.GetStringDataValue("onerel"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "dry_test",
|
||||
"onefile": "",
|
||||
}, "manyfiles")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
callbackCalls := 0
|
||||
|
||||
result := form.DrySubmit(func(txDao *daos.Dao) error {
|
||||
callbackCalls++
|
||||
return nil
|
||||
})
|
||||
if result != nil {
|
||||
t.Fatalf("Expected nil, got error %v", result)
|
||||
}
|
||||
|
||||
// ensure callback was called
|
||||
if callbackCalls != 1 {
|
||||
t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls)
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") == "dry_test" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "dry_test")
|
||||
}
|
||||
if recordAfter.GetStringDataValue("onefile") == "" {
|
||||
t.Fatal("Expected record.onefile to be set, got empty string")
|
||||
}
|
||||
|
||||
// file wasn't removed
|
||||
if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
|
||||
t.Fatal("onefile file should not have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "a",
|
||||
"onefile": "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
// ensure that validate is triggered
|
||||
// ---
|
||||
result := form.Submit()
|
||||
if result == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") == "a" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a")
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("onefile") == "" {
|
||||
t.Fatal("Expected record.onefile to be set, got empty string")
|
||||
}
|
||||
|
||||
// file wasn't removed
|
||||
if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
|
||||
t.Fatal("onefile file should not have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "test_save",
|
||||
"onefile": "",
|
||||
}, "manyfiles.1", "manyfiles") // replace + new file
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
result := form.Submit()
|
||||
if result != nil {
|
||||
t.Fatalf("Expected nil, got error %v", result)
|
||||
}
|
||||
|
||||
// ensure that the record changes were persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") != "test_save" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "test_save")
|
||||
}
|
||||
|
||||
if hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
|
||||
t.Fatal("Expected record.onefile to be deleted")
|
||||
}
|
||||
|
||||
manyfiles := (recordAfter.GetStringSliceDataValue("manyfiles"))
|
||||
if len(manyfiles) != 3 {
|
||||
t.Fatalf("Expected 3 manyfiles, got %d (%v)", len(manyfiles), manyfiles)
|
||||
}
|
||||
for _, f := range manyfiles {
|
||||
if !hasRecordFile(app, recordAfter, f) {
|
||||
t.Fatalf("Expected file %q to exist", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
|
||||
fs, _ := app.NewFilesystem()
|
||||
defer fs.Close()
|
||||
|
||||
fileKey := filepath.Join(
|
||||
record.Collection().Id,
|
||||
record.Id,
|
||||
filename,
|
||||
)
|
||||
|
||||
exists, _ := fs.Exists(fileKey)
|
||||
|
||||
return exists
|
||||
}
|
||||
59
forms/settings_upsert.go
Normal file
59
forms/settings_upsert.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// SettingsUpsert defines app settings upsert form.
|
||||
type SettingsUpsert struct {
|
||||
*core.Settings
|
||||
|
||||
app core.App
|
||||
}
|
||||
|
||||
// NewSettingsUpsert creates new settings upsert form from the provided app.
|
||||
func NewSettingsUpsert(app core.App) *SettingsUpsert {
|
||||
form := &SettingsUpsert{app: app}
|
||||
|
||||
// load the application settings into the form
|
||||
form.Settings, _ = app.Settings().Clone()
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *SettingsUpsert) Validate() error {
|
||||
return form.Settings.Validate()
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the loaded settings.
|
||||
//
|
||||
// On success the app settings will be refreshed with the form ones.
|
||||
func (form *SettingsUpsert) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
|
||||
saveErr := form.app.Dao().SaveParam(
|
||||
models.ParamAppSettings,
|
||||
form.Settings,
|
||||
encryptionKey,
|
||||
)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
|
||||
// explicitly trigger old logs deletion
|
||||
form.app.LogsDao().DeleteOldRequests(
|
||||
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
||||
)
|
||||
|
||||
// merge the application settings with the form ones
|
||||
return form.app.Settings().Merge(form.Settings)
|
||||
}
|
||||
130
forms/settings_upsert_test.go
Normal file
130
forms/settings_upsert_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestNewSettingsUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.Settings().Meta.AppName = "name_update"
|
||||
|
||||
form := forms.NewSettingsUpsert(app)
|
||||
|
||||
formSettings, _ := json.Marshal(form.Settings)
|
||||
appSettings, _ := json.Marshal(app.Settings())
|
||||
|
||||
if string(formSettings) != string(appSettings) {
|
||||
t.Errorf("Expected settings \n%s, got \n%s", string(appSettings), string(formSettings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewSettingsUpsert(app)
|
||||
|
||||
// check if settings validations are triggered
|
||||
// (there are already individual tests for each setting)
|
||||
form.Meta.AppName = ""
|
||||
form.Logs.MaxDays = -10
|
||||
|
||||
// parse errors
|
||||
err := form.Validate()
|
||||
jsonResult, _ := json.Marshal(err)
|
||||
|
||||
expected := `{"logs":{"maxDays":"must be no less than 0"},"meta":{"appName":"cannot be blank"}}`
|
||||
|
||||
if string(jsonResult) != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, string(jsonResult))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUpsertSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
encryption bool
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty (plain)
|
||||
{"{}", false, nil},
|
||||
// empty (encrypt)
|
||||
{"{}", true, nil},
|
||||
// failure - invalid data
|
||||
{
|
||||
`{"emailAuth": {"minPasswordLength": 1}, "logs": {"maxDays": -1}}`,
|
||||
false,
|
||||
[]string{"emailAuth", "logs"},
|
||||
},
|
||||
// success - valid data (plain)
|
||||
{
|
||||
`{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`,
|
||||
false,
|
||||
nil,
|
||||
},
|
||||
// success - valid data (encrypt)
|
||||
{
|
||||
`{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
if s.encryption {
|
||||
os.Setenv(app.EncryptionEnv(), security.RandomString(32))
|
||||
} else {
|
||||
os.Unsetenv(app.EncryptionEnv())
|
||||
}
|
||||
|
||||
form := forms.NewSettingsUpsert(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
formSettings, _ := json.Marshal(form.Settings)
|
||||
appSettings, _ := json.Marshal(app.Settings())
|
||||
|
||||
if string(formSettings) != string(appSettings) {
|
||||
t.Errorf("Expected app settings \n%s, got \n%s", string(appSettings), string(formSettings))
|
||||
}
|
||||
}
|
||||
}
|
||||
113
forms/user_email_change_confirm.go
Normal file
113
forms/user_email_change_confirm.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// UserEmailChangeConfirm defines a user email change confirmation form.
|
||||
type UserEmailChangeConfirm struct {
|
||||
app core.App
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewUserEmailChangeConfirm creates new user email change confirmation form.
|
||||
func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm {
|
||||
return &UserEmailChangeConfirm{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserEmailChangeConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Token,
|
||||
validation.Required,
|
||||
validation.By(form.checkToken),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.Required,
|
||||
validation.Length(1, 100),
|
||||
validation.By(form.checkPassword),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
_, _, err := form.parseToken(v)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeConfirm) checkPassword(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
user, _, _ := form.parseToken(form.Token)
|
||||
if user == nil || !user.ValidatePassword(v) {
|
||||
return validation.NewError("validation_invalid_password", "Missing or invalid user password.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeConfirm) parseToken(token string) (*models.User, string, error) {
|
||||
// check token payload
|
||||
claims, _ := security.ParseUnverifiedJWT(token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
if newEmail == "" {
|
||||
return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
|
||||
}
|
||||
|
||||
// ensure that there aren't other users with the new email
|
||||
if !form.app.Dao().IsUserEmailUnique(newEmail, "") {
|
||||
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
|
||||
}
|
||||
|
||||
// verify that the token is not expired and its signiture is valid
|
||||
user, err := form.app.Dao().FindUserByToken(
|
||||
token,
|
||||
form.app.Settings().UserEmailChangeToken.Secret,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return user, newEmail, nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the user email change confirmation form.
|
||||
// On success returns the updated user model associated to `form.Token`.
|
||||
func (form *UserEmailChangeConfirm) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, newEmail, err := form.parseToken(form.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Email = newEmail
|
||||
user.Verified = true
|
||||
user.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
if err := form.app.Dao().SaveUser(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
121
forms/user_email_change_confirm_test.go
Normal file
121
forms/user_email_change_confirm_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestUserEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"token", "password"}},
|
||||
// empty data
|
||||
{
|
||||
`{"token": "", "password": ""}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// invalid token payload
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTE2NDYxfQ.VjT3wc3IES--1Vye-1KRuk8RpO5mfdhVp2aKGbNluZ0",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.oPxbpJjcBpdZVBFbIW35FEXTCMkzJ7-RmQdHrz7zP3s",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// existing new email
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.RwHRZma5YpCwxHdj3y2obeBNy_GQrG6lT9CQHIUz6Ys",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// wrong confirmation password
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0",
|
||||
"password": "1234"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserEmailChangeConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
|
||||
// check whether the user was updated
|
||||
// ---
|
||||
if user.Email != newEmail {
|
||||
t.Errorf("(%d) Expected user email %q, got %q", i, newEmail, user.Email)
|
||||
}
|
||||
|
||||
if !user.Verified {
|
||||
t.Errorf("(%d) Expected user to be verified, got false", i)
|
||||
}
|
||||
|
||||
// shouldn't validate second time due to refreshed user token
|
||||
if err := form.Validate(); err == nil {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
57
forms/user_email_change_request.go
Normal file
57
forms/user_email_change_request.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserEmailChangeConfirm defines a user email change request form.
|
||||
type UserEmailChangeRequest struct {
|
||||
app core.App
|
||||
user *models.User
|
||||
|
||||
NewEmail string `form:"newEmail" json:"newEmail"`
|
||||
}
|
||||
|
||||
// NewUserEmailChangeRequest creates a new user email change request form.
|
||||
func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest {
|
||||
return &UserEmailChangeRequest{
|
||||
app: app,
|
||||
user: user,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserEmailChangeRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.NewEmail,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.app.Dao().IsUserEmailUnique(v, "") {
|
||||
return validation.NewError("validation_user_email_exists", "User email already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and sends the change email request.
|
||||
func (form *UserEmailChangeRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mails.SendUserChangeEmail(form.app, form.user, form.NewEmail)
|
||||
}
|
||||
87
forms/user_email_change_request_test.go
Normal file
87
forms/user_email_change_request_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
user, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"newEmail"}},
|
||||
// empty data
|
||||
{
|
||||
`{"newEmail": ""}`,
|
||||
[]string{"newEmail"},
|
||||
},
|
||||
// invalid email
|
||||
{
|
||||
`{"newEmail": "invalid"}`,
|
||||
[]string{"newEmail"},
|
||||
},
|
||||
// existing email token
|
||||
{
|
||||
`{"newEmail": "test@example.com"}`,
|
||||
[]string{"newEmail"},
|
||||
},
|
||||
// valid new email
|
||||
{
|
||||
`{"newEmail": "test_new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form := forms.NewUserEmailChangeRequest(testApp, user)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
expectedMails := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectedMails = 0
|
||||
}
|
||||
if testApp.TestMailer.TotalSend != expectedMails {
|
||||
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
forms/user_email_login.go
Normal file
52
forms/user_email_login.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserEmailLogin defines a user email/pass login form.
|
||||
type UserEmailLogin struct {
|
||||
app core.App
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewUserEmailLogin creates a new user email/pass login form.
|
||||
func NewUserEmailLogin(app core.App) *UserEmailLogin {
|
||||
form := &UserEmailLogin{
|
||||
app: app,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserEmailLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.Email),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized user model.
|
||||
func (form *UserEmailLogin) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := form.app.Dao().FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !user.ValidatePassword(form.Password) {
|
||||
return nil, validation.NewError("invalid_login", "Invalid login credentials.")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
106
forms/user_email_login_test.go
Normal file
106
forms/user_email_login_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserEmailLoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"email", "password"}},
|
||||
// empty data
|
||||
{
|
||||
`{"email": "","password": ""}`,
|
||||
[]string{"email", "password"},
|
||||
},
|
||||
// invalid email
|
||||
{
|
||||
`{"email": "invalid","password": "123"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// valid email
|
||||
{
|
||||
`{"email": "test@example.com","password": "123"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserEmailLogin(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEmailLoginSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
// invalid email
|
||||
{"invalid", "123456", true},
|
||||
// missing user
|
||||
{"missing@example.com", "123456", true},
|
||||
// invalid password
|
||||
{"test@example.com", "123", true},
|
||||
// valid email and password
|
||||
{"test@example.com", "123456", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserEmailLogin(app)
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.expectError && user.Email != s.email {
|
||||
t.Errorf("(%d) Expected user with email %q, got %q", i, s.email, user.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
133
forms/user_oauth2_login.go
Normal file
133
forms/user_oauth2_login.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// UserOauth2Login defines a user Oauth2 login form.
|
||||
type UserOauth2Login struct {
|
||||
app core.App
|
||||
|
||||
// The name of the OAuth2 client provider (eg. "google")
|
||||
Provider string `form:"provider" json:"provider"`
|
||||
|
||||
// The authorization code returned from the initial request.
|
||||
Code string `form:"code" json:"code"`
|
||||
|
||||
// The code verifier sent with the initial request as part of the code_challenge.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
|
||||
}
|
||||
|
||||
// NewUserOauth2Login creates a new user Oauth2 login form.
|
||||
func NewUserOauth2Login(app core.App) *UserOauth2Login {
|
||||
return &UserOauth2Login{app: app}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserOauth2Login) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.CodeVerifier, validation.Required),
|
||||
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserOauth2Login) checkProviderName(value any) error {
|
||||
name, _ := value.(string)
|
||||
|
||||
config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
|
||||
if !ok || !config.Enabled {
|
||||
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized user model and the fetched provider's data.
|
||||
func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
provider, err := auth.NewProviderByName(form.Provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
config, _ := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
config.SetupProvider(provider)
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(
|
||||
form.Code,
|
||||
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// fetch auth user
|
||||
authData, err := provider.FetchAuthUser(token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// login/register the auth user
|
||||
user, _ := form.app.Dao().FindUserByEmail(authData.Email)
|
||||
if user != nil {
|
||||
// update the existing user's verified state
|
||||
if !user.Verified {
|
||||
user.Verified = true
|
||||
if err := form.app.Dao().SaveUser(user); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !config.AllowRegistrations {
|
||||
// registration of new users is not allowed via the Oauth2 provider
|
||||
return nil, authData, errors.New("Cannot find user with the authorized email.")
|
||||
}
|
||||
|
||||
// create new user
|
||||
user = &models.User{Verified: true}
|
||||
upsertForm := NewUserUpsert(form.app, user)
|
||||
upsertForm.Email = authData.Email
|
||||
upsertForm.Password = security.RandomString(30)
|
||||
upsertForm.PasswordConfirm = upsertForm.Password
|
||||
|
||||
event := &core.UserOauth2RegisterEvent{
|
||||
User: user,
|
||||
AuthData: authData,
|
||||
}
|
||||
|
||||
if err := form.app.OnUserBeforeOauth2Register().Trigger(event); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
|
||||
if err := upsertForm.Submit(); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
|
||||
if err := form.app.OnUserAfterOauth2Register().Trigger(event); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, authData, nil
|
||||
}
|
||||
75
forms/user_oauth2_login_test.go
Normal file
75
forms/user_oauth2_login_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"provider", "code", "codeVerifier", "redirectUrl"}},
|
||||
// empty data
|
||||
{
|
||||
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
},
|
||||
// missing provider
|
||||
{
|
||||
`{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// disabled provider
|
||||
{
|
||||
`{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// enabled provider
|
||||
{
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserOauth2Login(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @todo consider mocking a Oauth2 provider to test Submit
|
||||
78
forms/user_password_reset_confirm.go
Normal file
78
forms/user_password_reset_confirm.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserPasswordResetConfirm defines a user password reset confirmation form.
|
||||
type UserPasswordResetConfirm struct {
|
||||
app core.App
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// NewUserPasswordResetConfirm creates new user password reset confirmation form.
|
||||
func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm {
|
||||
return &UserPasswordResetConfirm{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserPasswordResetConfirm) Validate() error {
|
||||
minPasswordLength := form.app.Settings().EmailAuth.MinPasswordLength
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserPasswordResetConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
user, err := form.app.Dao().FindUserByToken(
|
||||
v,
|
||||
form.app.Settings().UserPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the updated user model associated to `form.Token`.
|
||||
func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := form.app.Dao().FindUserByToken(
|
||||
form.Token,
|
||||
form.app.Settings().UserPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := user.SetPassword(form.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.app.Dao().SaveUser(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
165
forms/user_password_reset_confirm_test.go
Normal file
165
forms/user_password_reset_confirm_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestUserPasswordResetConfirmValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"token", "password", "passwordConfirm"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"token":"","password":"","passwordConfirm":""}`,
|
||||
[]string{"token", "password", "passwordConfirm"},
|
||||
},
|
||||
// invalid password length
|
||||
{
|
||||
`{"token":"invalid","password":"1234","passwordConfirm":"1234"}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// mismatched passwords
|
||||
{
|
||||
`{"token":"invalid","password":"12345678","passwordConfirm":"87654321"}`,
|
||||
[]string{"token", "passwordConfirm"},
|
||||
},
|
||||
// invalid JWT token
|
||||
{
|
||||
`{"token":"invalid","password":"12345678","passwordConfirm":"12345678"}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserPasswordResetConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPasswordResetConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserPasswordResetConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenUserId, _ := claims["id"]
|
||||
|
||||
if user.Id != tokenUserId {
|
||||
t.Errorf("(%d) Expected user with id %s, got %v", i, tokenUserId, user)
|
||||
}
|
||||
|
||||
if !user.LastResetSentAt.IsZero() {
|
||||
t.Errorf("(%d) Expected user.LastResetSentAt to be empty, got %v", i, user.LastResetSentAt)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected the user password to have been updated to %q", i, form.Password)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
forms/user_password_reset_request.go
Normal file
70
forms/user_password_reset_request.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// UserPasswordResetRequest defines a user password reset request form.
|
||||
type UserPasswordResetRequest struct {
|
||||
app core.App
|
||||
resendThreshold float64
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// NewUserPasswordResetRequest creates new user password reset request form.
|
||||
func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest {
|
||||
return &UserPasswordResetRequest{
|
||||
app: app,
|
||||
resendThreshold: 120, // 2 min
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// This method doesn't checks whether user with `form.Email` exists (this is done on Submit).
|
||||
func (form *UserPasswordResetRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success sends a password reset email to the `form.Email` user.
|
||||
func (form *UserPasswordResetRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := form.app.Dao().FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := user.LastResetSentAt.Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
|
||||
return errors.New("You've already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendUserPasswordReset(form.app, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
user.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.app.Dao().SaveUser(user)
|
||||
}
|
||||
153
forms/user_password_reset_request_test.go
Normal file
153
forms/user_password_reset_request_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestUserPasswordResetRequestValidate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"email":""}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// invalid email format
|
||||
{
|
||||
`{"email":"invalid"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// valid email
|
||||
{
|
||||
`{"email":"new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserPasswordResetRequest(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPasswordResetRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty field (Validate call check)
|
||||
{
|
||||
`{"email":""}`,
|
||||
true,
|
||||
},
|
||||
// invalid email field (Validate call check)
|
||||
{
|
||||
`{"email":"invalid"}`,
|
||||
true,
|
||||
},
|
||||
// nonexisting user
|
||||
{
|
||||
`{"email":"missing@example.com"}`,
|
||||
true,
|
||||
},
|
||||
// existing user
|
||||
{
|
||||
`{"email":"test@example.com"}`,
|
||||
false,
|
||||
},
|
||||
// existing user - reached send threshod
|
||||
{
|
||||
`{"email":"test@example.com"}`,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
now := types.NowDateTime()
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form := forms.NewUserPasswordResetRequest(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
expectedMails := 1
|
||||
if s.expectError {
|
||||
expectedMails = 0
|
||||
}
|
||||
if testApp.TestMailer.TotalSend != expectedMails {
|
||||
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether LastResetSentAt was updated
|
||||
user, err := testApp.Dao().FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
|
||||
continue
|
||||
}
|
||||
|
||||
if user.LastResetSentAt.Time().Sub(now.Time()) < 0 {
|
||||
t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
118
forms/user_upsert.go
Normal file
118
forms/user_upsert.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// UserUpsert defines a user upsert (create/update) form.
|
||||
type UserUpsert struct {
|
||||
app core.App
|
||||
user *models.User
|
||||
isCreate bool
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// NewUserUpsert creates new upsert form for the provided user model
|
||||
// (pass an empty user model instance (`&models.User{}`) for create).
|
||||
func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
|
||||
form := &UserUpsert{
|
||||
app: app,
|
||||
user: user,
|
||||
isCreate: !user.HasId(),
|
||||
}
|
||||
|
||||
// load defaults
|
||||
form.Email = user.Email
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserUpsert) Validate() error {
|
||||
config := form.app.Settings()
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
validation.By(form.checkEmailDomain),
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.isCreate, validation.Required),
|
||||
validation.Length(config.EmailAuth.MinPasswordLength, 100),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
validation.When(form.isCreate || form.Password != "", validation.Required),
|
||||
validation.By(validators.Compare(form.Password)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserUpsert) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if v == "" || form.app.Dao().IsUserEmailUnique(v, form.user.Id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validation.NewError("validation_user_email_exists", "User email already exists.")
|
||||
}
|
||||
|
||||
func (form *UserUpsert) checkEmailDomain(value any) error {
|
||||
val, _ := value.(string)
|
||||
if val == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
domain := val[strings.LastIndex(val, "@")+1:]
|
||||
only := form.app.Settings().EmailAuth.OnlyDomains
|
||||
except := form.app.Settings().EmailAuth.ExceptDomains
|
||||
|
||||
// only domains check
|
||||
if len(only) > 0 && !list.ExistInSlice(domain, only) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
|
||||
}
|
||||
|
||||
// except domains check
|
||||
if len(except) > 0 && list.ExistInSlice(domain, except) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form user model.
|
||||
func (form *UserUpsert) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if form.Password != "" {
|
||||
form.user.SetPassword(form.Password)
|
||||
}
|
||||
|
||||
if !form.isCreate && form.Email != form.user.Email {
|
||||
form.user.Verified = false
|
||||
form.user.LastVerificationSentAt = types.DateTime{} // reset
|
||||
}
|
||||
|
||||
form.user.Email = form.Email
|
||||
|
||||
return form.app.Dao().SaveUser(form.user)
|
||||
}
|
||||
242
forms/user_upsert_test.go
Normal file
242
forms/user_upsert_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestNewUserUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
user := &models.User{}
|
||||
user.Email = "new@example.com"
|
||||
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
|
||||
// check defaults loading
|
||||
if form.Email != user.Email {
|
||||
t.Fatalf("Expected email %q, got %q", user.Email, form.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// mock app constraints
|
||||
app.Settings().EmailAuth.MinPasswordLength = 5
|
||||
app.Settings().EmailAuth.ExceptDomains = []string{"test.com"}
|
||||
app.Settings().EmailAuth.OnlyDomains = []string{"example.com", "test.com"}
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data - create
|
||||
{
|
||||
"",
|
||||
`{}`,
|
||||
[]string{"email", "password", "passwordConfirm"},
|
||||
},
|
||||
// empty data - update
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{}`,
|
||||
[]string{},
|
||||
},
|
||||
// invalid email address
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"invalid"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// unique email constraint check (same email, aka. no changes)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
// unique email constraint check (existing email)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test2@something.com"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// unique email constraint check (new email)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
// EmailAuth.OnlyDomains constraints check
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test@something.com"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// EmailAuth.ExceptDomains constraints check
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test@test.com"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// password length constraint check
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"password":"1234", "passwordConfirm": "1234"}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// passwords mismatch
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"password":"12345", "passwordConfirm": "54321"}`,
|
||||
[]string{"passwordConfirm"},
|
||||
},
|
||||
// valid data - all fields
|
||||
{
|
||||
"",
|
||||
`{"email":"new@example.com","password":"12345","passwordConfirm":"12345"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
user := &models.User{}
|
||||
if s.id != "" {
|
||||
user, _ = app.Dao().FindUserById(s.id)
|
||||
}
|
||||
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsertSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty fields - create (Validate call check)
|
||||
{
|
||||
"",
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
// empty fields - update (Validate call check)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
// updating with existing user email
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test2@example.com"}`,
|
||||
true,
|
||||
},
|
||||
// updating with nonexisting user email
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"update_new@example.com"}`,
|
||||
false,
|
||||
},
|
||||
// changing password
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"password":"123456789","passwordConfirm":"123456789"}`,
|
||||
false,
|
||||
},
|
||||
// creating user (existing email)
|
||||
{
|
||||
"",
|
||||
`{"email":"test3@example.com","password":"123456789","passwordConfirm":"123456789"}`,
|
||||
true,
|
||||
},
|
||||
// creating user (new email)
|
||||
{
|
||||
"",
|
||||
`{"email":"create_new@example.com","password":"123456789","passwordConfirm":"123456789"}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
user := &models.User{}
|
||||
originalUser := &models.User{}
|
||||
if s.id != "" {
|
||||
user, _ = app.Dao().FindUserById(s.id)
|
||||
originalUser, _ = app.Dao().FindUserById(s.id)
|
||||
}
|
||||
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
if user.Email != form.Email {
|
||||
t.Errorf("(%d) Expected email %q, got %q", i, form.Email, user.Email)
|
||||
}
|
||||
|
||||
// on email change Verified should reset
|
||||
if user.Email != originalUser.Email && user.Verified {
|
||||
t.Errorf("(%d) Expected Verified to be false, got true", i)
|
||||
}
|
||||
|
||||
if form.Password != "" && !user.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected password to be updated to %q", i, form.Password)
|
||||
}
|
||||
if form.Password != "" && originalUser.TokenKey == user.TokenKey {
|
||||
t.Errorf("(%d) Expected TokenKey to change, got %q", i, user.TokenKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
forms/user_verification_confirm.go
Normal file
73
forms/user_verification_confirm.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserVerificationConfirm defines a user email confirmation form.
|
||||
type UserVerificationConfirm struct {
|
||||
app core.App
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
}
|
||||
|
||||
// NewUserVerificationConfirm creates a new user email confirmation form.
|
||||
func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm {
|
||||
return &UserVerificationConfirm{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserVerificationConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserVerificationConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
user, err := form.app.Dao().FindUserByToken(
|
||||
v,
|
||||
form.app.Settings().UserVerificationToken.Secret,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the verified user model associated to `form.Token`.
|
||||
func (form *UserVerificationConfirm) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := form.app.Dao().FindUserByToken(
|
||||
form.Token,
|
||||
form.app.Settings().UserVerificationToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return user, nil // already verified
|
||||
}
|
||||
|
||||
user.Verified = true
|
||||
|
||||
if err := form.app.Dao().SaveUser(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
140
forms/user_verification_confirm_test.go
Normal file
140
forms/user_verification_confirm_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestUserVerificationConfirmValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"token":""}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// invalid JWT token
|
||||
{
|
||||
`{"token":"invalid"}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserVerificationConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserVerificationConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
// expired token (Validate call check)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`,
|
||||
true,
|
||||
},
|
||||
// valid token (already verified user)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`,
|
||||
false,
|
||||
},
|
||||
// valid token (unverified user)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTkwNjEwNjQyMX0.KbSucLGasQqTkGxUgqaaCjKNOHJ3ZVkL1WTzSApc6oM"}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserVerificationConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenUserId, _ := claims["id"]
|
||||
|
||||
if user.Id != tokenUserId {
|
||||
t.Errorf("(%d) Expected user.Id %q, got %q", i, tokenUserId, user.Id)
|
||||
}
|
||||
|
||||
if !user.Verified {
|
||||
t.Errorf("(%d) Expected user.Verified to be true, got false", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
forms/user_verification_request.go
Normal file
74
forms/user_verification_request.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// UserVerificationRequest defines a user email verification request form.
|
||||
type UserVerificationRequest struct {
|
||||
app core.App
|
||||
resendThreshold float64
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// NewUserVerificationRequest creates a new user email verification request form.
|
||||
func NewUserVerificationRequest(app core.App) *UserVerificationRequest {
|
||||
return &UserVerificationRequest{
|
||||
app: app,
|
||||
resendThreshold: 120, // 2 min
|
||||
}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// // This method doesn't verify that user with `form.Email` exists (this is done on Submit).
|
||||
func (form *UserVerificationRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.Email,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and sends a verification request email
|
||||
// to the `form.Email` user.
|
||||
func (form *UserVerificationRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := form.app.Dao().FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastVerificationSentAt := user.LastVerificationSentAt.Time()
|
||||
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
|
||||
return errors.New("A verification email was already sent.")
|
||||
}
|
||||
|
||||
if err := mails.SendUserVerification(form.app, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
user.LastVerificationSentAt = types.NowDateTime()
|
||||
|
||||
return form.app.Dao().SaveUser(user)
|
||||
}
|
||||
171
forms/user_verification_request_test.go
Normal file
171
forms/user_verification_request_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestUserVerificationRequestValidate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"email":""}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// invalid email format
|
||||
{
|
||||
`{"email":"invalid"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// valid email
|
||||
{
|
||||
`{"email":"new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserVerificationRequest(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserVerificationRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
expectMail bool
|
||||
}{
|
||||
// empty field (Validate call check)
|
||||
{
|
||||
`{"email":""}`,
|
||||
true,
|
||||
false,
|
||||
},
|
||||
// invalid email field (Validate call check)
|
||||
{
|
||||
`{"email":"invalid"}`,
|
||||
true,
|
||||
false,
|
||||
},
|
||||
// nonexisting user
|
||||
{
|
||||
`{"email":"missing@example.com"}`,
|
||||
true,
|
||||
false,
|
||||
},
|
||||
// existing user (already verified)
|
||||
{
|
||||
`{"email":"test@example.com"}`,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
// existing user (already verified) - repeating request to test threshod skip
|
||||
{
|
||||
`{"email":"test@example.com"}`,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
// existing user (unverified)
|
||||
{
|
||||
`{"email":"test2@example.com"}`,
|
||||
false,
|
||||
true,
|
||||
},
|
||||
// existing user (inverified) - reached send threshod
|
||||
{
|
||||
`{"email":"test2@example.com"}`,
|
||||
true,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
now := types.NowDateTime()
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form := forms.NewUserVerificationRequest(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
expectedMails := 0
|
||||
if s.expectMail {
|
||||
expectedMails = 1
|
||||
}
|
||||
if testApp.TestMailer.TotalSend != expectedMails {
|
||||
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := testApp.Dao().FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether LastVerificationSentAt was updated
|
||||
if !user.Verified && user.LastVerificationSentAt.Time().Sub(now.Time()) < 0 {
|
||||
t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
forms/validators/file.go
Normal file
63
forms/validators/file.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
// UploadedFileSize checks whether the validated `rest.UploadedFile`
|
||||
// size is no more than the provided maxBytes.
|
||||
//
|
||||
// Example:
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
|
||||
func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(*rest.UploadedFile)
|
||||
if v == nil {
|
||||
return nil // nothing to validate
|
||||
}
|
||||
|
||||
if binary.Size(v.Bytes()) > maxBytes {
|
||||
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UploadedFileMimeType checks whether the validated `rest.UploadedFile`
|
||||
// mimetype is within the provided allowed mime types.
|
||||
//
|
||||
// Example:
|
||||
// validMimeTypes := []string{"test/plain","image/jpeg"}
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
|
||||
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(*rest.UploadedFile)
|
||||
if v == nil {
|
||||
return nil // nothing to validate
|
||||
}
|
||||
|
||||
if len(validTypes) == 0 {
|
||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||
}
|
||||
|
||||
filetype := http.DetectContentType(v.Bytes())
|
||||
|
||||
for _, t := range validTypes {
|
||||
if t == filetype {
|
||||
return nil // valid
|
||||
}
|
||||
}
|
||||
|
||||
return validation.NewError("validation_invalid_mime_type", fmt.Sprintf(
|
||||
"The following mime types are only allowed: %s.",
|
||||
strings.Join(validTypes, ","),
|
||||
))
|
||||
}
|
||||
}
|
||||
92
forms/validators/file_test.go
Normal file
92
forms/validators/file_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package validators_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
func TestUploadedFileSize(t *testing.T) {
|
||||
data, mp, err := tests.MockMultipartData(nil, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", data)
|
||||
req.Header.Add("Content-Type", mp.FormDataContentType())
|
||||
|
||||
files, err := rest.FindUploadedFiles(req, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("Expected one test file, got %d", len(files))
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
maxBytes int
|
||||
file *rest.UploadedFile
|
||||
expectError bool
|
||||
}{
|
||||
{0, nil, false},
|
||||
{4, nil, false},
|
||||
{3, files[0], true}, // all test files have "test" as content
|
||||
{4, files[0], false},
|
||||
{5, files[0], false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
err := validators.UploadedFileSize(s.maxBytes)(s.file)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadedFileMimeType(t *testing.T) {
|
||||
data, mp, err := tests.MockMultipartData(nil, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", data)
|
||||
req.Header.Add("Content-Type", mp.FormDataContentType())
|
||||
|
||||
files, err := rest.FindUploadedFiles(req, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("Expected one test file, got %d", len(files))
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
types []string
|
||||
file *rest.UploadedFile
|
||||
expectError bool
|
||||
}{
|
||||
{nil, nil, false},
|
||||
{[]string{"image/jpeg"}, nil, false},
|
||||
{[]string{}, files[0], true},
|
||||
{[]string{"image/jpeg"}, files[0], true},
|
||||
// test files are detected as "text/plain; charset=utf-8" content type
|
||||
{[]string{"image/jpeg", "text/plain; charset=utf-8"}, files[0], false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
err := validators.UploadedFileMimeType(s.types)(s.file)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
418
forms/validators/record_data.go
Normal file
418
forms/validators/record_data.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
var requiredErr = validation.NewError("validation_required", "Missing required value")
|
||||
|
||||
// NewRecordDataValidator creates new [models.Record] data validator
|
||||
// using the provided record constraints and schema.
|
||||
//
|
||||
// Example:
|
||||
// validator := NewRecordDataValidator(app.Dao(), record, nil)
|
||||
// err := validator.Validate(map[string]any{"test":123})
|
||||
func NewRecordDataValidator(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
uploadedFiles []*rest.UploadedFile,
|
||||
) *RecordDataValidator {
|
||||
return &RecordDataValidator{
|
||||
dao: dao,
|
||||
record: record,
|
||||
uploadedFiles: uploadedFiles,
|
||||
}
|
||||
}
|
||||
|
||||
// RecordDataValidator defines a model.Record data validator
|
||||
// using the provided record constraints and schema.
|
||||
type RecordDataValidator struct {
|
||||
dao *daos.Dao
|
||||
record *models.Record
|
||||
uploadedFiles []*rest.UploadedFile
|
||||
}
|
||||
|
||||
// Validate validates the provided `data` by checking it against
|
||||
// the validator record constraints and schema.
|
||||
func (validator *RecordDataValidator) Validate(data map[string]any) error {
|
||||
keyedSchema := validator.record.Collection().Schema.AsMap()
|
||||
if len(keyedSchema) == 0 {
|
||||
return nil // no fields to check
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return validation.NewError("validation_empty_data", "No data to validate")
|
||||
}
|
||||
|
||||
errs := validation.Errors{}
|
||||
|
||||
// check for unknown fields
|
||||
for key := range data {
|
||||
if _, ok := keyedSchema[key]; !ok {
|
||||
errs[key] = validation.NewError("validation_unknown_field", "Unknown field")
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
|
||||
for key, field := range keyedSchema {
|
||||
// normalize value to emulate the same behavior
|
||||
// when fetching or persisting the record model
|
||||
value := field.PrepareValue(data[key])
|
||||
|
||||
// check required constraint
|
||||
if field.Required && validation.Required.Validate(value) != nil {
|
||||
errs[key] = requiredErr
|
||||
continue
|
||||
}
|
||||
|
||||
// validate field value by its field type
|
||||
if err := validator.checkFieldValue(field, value); err != nil {
|
||||
errs[key] = err
|
||||
continue
|
||||
}
|
||||
|
||||
// check unique constraint
|
||||
if field.Unique && !validator.dao.IsRecordValueUnique(
|
||||
validator.record.Collection(),
|
||||
key,
|
||||
value,
|
||||
validator.record.GetId(),
|
||||
) {
|
||||
errs[key] = validation.NewError("validation_not_unique", "Value must be unique")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField, value any) error {
|
||||
switch field.Type {
|
||||
case schema.FieldTypeText:
|
||||
return validator.checkTextValue(field, value)
|
||||
case schema.FieldTypeNumber:
|
||||
return validator.checkNumberValue(field, value)
|
||||
case schema.FieldTypeBool:
|
||||
return validator.checkBoolValue(field, value)
|
||||
case schema.FieldTypeEmail:
|
||||
return validator.checkEmailValue(field, value)
|
||||
case schema.FieldTypeUrl:
|
||||
return validator.checkUrlValue(field, value)
|
||||
case schema.FieldTypeDate:
|
||||
return validator.checkDateValue(field, value)
|
||||
case schema.FieldTypeSelect:
|
||||
return validator.checkSelectValue(field, value)
|
||||
case schema.FieldTypeJson:
|
||||
return validator.checkJsonValue(field, value)
|
||||
case schema.FieldTypeFile:
|
||||
return validator.checkFileValue(field, value)
|
||||
case schema.FieldTypeRelation:
|
||||
return validator.checkRelationValue(field, value)
|
||||
case schema.FieldTypeUser:
|
||||
return validator.checkUserValue(field, value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField, value any) error {
|
||||
val, _ := value.(string)
|
||||
if val == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.TextOptions)
|
||||
|
||||
if options.Min != nil && len(val) < *options.Min {
|
||||
return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min))
|
||||
}
|
||||
|
||||
if options.Max != nil && len(val) > *options.Max {
|
||||
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max))
|
||||
}
|
||||
|
||||
if options.Pattern != "" {
|
||||
match, _ := regexp.MatchString(options.Pattern, val)
|
||||
if !match {
|
||||
return validation.NewError("validation_invalid_format", "Invalid value format")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField, value any) error {
|
||||
if value == nil {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
val, _ := value.(float64)
|
||||
options, _ := field.Options.(*schema.NumberOptions)
|
||||
|
||||
if options.Min != nil && val < *options.Min {
|
||||
return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *options.Min))
|
||||
}
|
||||
|
||||
if options.Max != nil && val > *options.Max {
|
||||
return validation.NewError("validation_max_number_constraint", fmt.Sprintf("Must be less than %f", *options.Max))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkBoolValue(field *schema.SchemaField, value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkEmailValue(field *schema.SchemaField, value any) error {
|
||||
val, _ := value.(string)
|
||||
if val == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if is.Email.Validate(val) != nil {
|
||||
return validation.NewError("validation_invalid_email", "Must be a valid email")
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.EmailOptions)
|
||||
domain := val[strings.LastIndex(val, "@")+1:]
|
||||
|
||||
// only domains check
|
||||
if len(options.OnlyDomains) > 0 && !list.ExistInSlice(domain, options.OnlyDomains) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed")
|
||||
}
|
||||
|
||||
// except domains check
|
||||
if len(options.ExceptDomains) > 0 && list.ExistInSlice(domain, options.ExceptDomains) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, value any) error {
|
||||
val, _ := value.(string)
|
||||
if val == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if is.URL.Validate(val) != nil {
|
||||
return validation.NewError("validation_invalid_url", "Must be a valid url")
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.UrlOptions)
|
||||
|
||||
// extract host/domain
|
||||
u, _ := url.Parse(val)
|
||||
host := u.Host
|
||||
|
||||
// only domains check
|
||||
if len(options.OnlyDomains) > 0 && !list.ExistInSlice(host, options.OnlyDomains) {
|
||||
return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed")
|
||||
}
|
||||
|
||||
// except domains check
|
||||
if len(options.ExceptDomains) > 0 && list.ExistInSlice(host, options.ExceptDomains) {
|
||||
return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
|
||||
val, _ := value.(types.DateTime)
|
||||
|
||||
if val.IsZero() {
|
||||
if field.Required {
|
||||
return requiredErr
|
||||
}
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.DateOptions)
|
||||
|
||||
if !options.Min.IsZero() {
|
||||
if err := validation.Min(options.Min.Time()).Validate(val.Time()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !options.Max.IsZero() {
|
||||
if err := validation.Max(options.Max.Time()).Validate(val.Time()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField, value any) error {
|
||||
normalizedVal := list.ToUniqueStringSlice(value)
|
||||
if len(normalizedVal) == 0 {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.SelectOptions)
|
||||
|
||||
// check max selected items
|
||||
if len(normalizedVal) > options.MaxSelect {
|
||||
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
|
||||
}
|
||||
|
||||
// check against the allowed values
|
||||
for _, val := range normalizedVal {
|
||||
if !list.ExistInSlice(val, options.Values) {
|
||||
return validation.NewError("validation_invalid_value", "Invalid value "+val)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error {
|
||||
raw, _ := types.ParseJsonRaw(value)
|
||||
if len(raw) == 0 {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if is.JSON.Validate(value) != nil {
|
||||
return validation.NewError("validation_invalid_json", "Must be a valid json value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField, value any) error {
|
||||
// normalize value access
|
||||
var names []string
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
names = v
|
||||
case string:
|
||||
names = []string{v}
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.FileOptions)
|
||||
|
||||
if len(names) > options.MaxSelect {
|
||||
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
|
||||
}
|
||||
|
||||
// extract the uploaded files
|
||||
files := []*rest.UploadedFile{}
|
||||
if len(validator.uploadedFiles) > 0 {
|
||||
for _, file := range validator.uploadedFiles {
|
||||
if list.ExistInSlice(file.Name(), names) {
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
// check size
|
||||
if err := UploadedFileSize(options.MaxSize)(file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check type
|
||||
if len(options.MimeTypes) > 0 {
|
||||
if err := UploadedFileMimeType(options.MimeTypes)(file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaField, value any) error {
|
||||
// normalize value access
|
||||
var ids []string
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
ids = v
|
||||
case string:
|
||||
ids = []string{v}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
|
||||
if len(ids) > options.MaxSelect {
|
||||
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
|
||||
}
|
||||
|
||||
// check if the related records exist
|
||||
// ---
|
||||
relCollection, err := validator.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_missing_rel_collection", "Relation connection is missing or cannot be accessed")
|
||||
}
|
||||
|
||||
var total int
|
||||
validator.dao.RecordQuery(relCollection).
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
|
||||
Row(&total)
|
||||
if total != len(ids) {
|
||||
return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids")
|
||||
}
|
||||
// ---
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkUserValue(field *schema.SchemaField, value any) error {
|
||||
// normalize value access
|
||||
var ids []string
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
ids = v
|
||||
case string:
|
||||
ids = []string{v}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.UserOptions)
|
||||
|
||||
if len(ids) > options.MaxSelect {
|
||||
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
|
||||
}
|
||||
|
||||
// check if the related users exist
|
||||
var total int
|
||||
validator.dao.UserQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
|
||||
Row(&total)
|
||||
if total != len(ids) {
|
||||
return validation.NewError("validation_missing_users", "Failed to fetch all users with the provided ids")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
1443
forms/validators/record_data_test.go
Normal file
1443
forms/validators/record_data_test.go
Normal file
File diff suppressed because it is too large
Load Diff
21
forms/validators/string.go
Normal file
21
forms/validators/string.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
)
|
||||
|
||||
// Compare checks whether the validated value matches another string.
|
||||
//
|
||||
// Example:
|
||||
// validation.Field(&form.PasswordConfirm, validation.By(validators.Compare(form.Password)))
|
||||
func Compare(valueToCompare string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if v != valueToCompare {
|
||||
return validation.NewError("validation_values_mismatch", "Values don't match.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
30
forms/validators/string_test.go
Normal file
30
forms/validators/string_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package validators_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
)
|
||||
|
||||
func TestCompare(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
valA string
|
||||
valB string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"", "456", true},
|
||||
{"123", "", true},
|
||||
{"123", "456", true},
|
||||
{"123", "123", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
err := validators.Compare(s.valA)(s.valB)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
2
forms/validators/validators.go
Normal file
2
forms/validators/validators.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package validators implements custom shared PocketBase validators.
|
||||
package validators
|
||||
Reference in New Issue
Block a user