mirror of
https://github.com/pocketbase/pocketbase.git
synced 2025-01-27 23:46:18 +02:00
290 lines
8.5 KiB
Go
290 lines
8.5 KiB
Go
package forms
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
|
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/core/validators"
|
|
"github.com/pocketbase/pocketbase/tools/security"
|
|
"github.com/spf13/cast"
|
|
)
|
|
|
|
const (
|
|
accessLevelDefault = iota
|
|
accessLevelManager
|
|
accessLevelSuperuser
|
|
)
|
|
|
|
type RecordUpsert struct {
|
|
ctx context.Context
|
|
app core.App
|
|
record *core.Record
|
|
accessLevel int
|
|
|
|
// extra password fields
|
|
Password string `form:"password" json:"password"`
|
|
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
|
OldPassword string `form:"oldPassword" json:"oldPassword"`
|
|
}
|
|
|
|
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
|
|
// (for create you could pass a pointer to an empty Record - core.NewRecord(collection)).
|
|
func NewRecordUpsert(app core.App, record *core.Record) *RecordUpsert {
|
|
form := &RecordUpsert{
|
|
ctx: context.Background(),
|
|
app: app,
|
|
record: record,
|
|
}
|
|
|
|
return form
|
|
}
|
|
|
|
// SetContext assigns ctx as context of the current form.
|
|
func (form *RecordUpsert) SetContext(ctx context.Context) {
|
|
form.ctx = ctx
|
|
}
|
|
|
|
// SetApp replaces the current form app instance.
|
|
//
|
|
// This could be used for example if you want to change at later stage
|
|
// before submission to change from regular -> transactional app instance.
|
|
func (form *RecordUpsert) SetApp(app core.App) {
|
|
form.app = app
|
|
}
|
|
|
|
// SetRecord replaces the current form record instance.
|
|
func (form *RecordUpsert) SetRecord(record *core.Record) {
|
|
form.record = record
|
|
}
|
|
|
|
// ResetAccess resets the form access level to the accessLevelDefault.
|
|
func (form *RecordUpsert) ResetAccess() {
|
|
form.accessLevel = accessLevelDefault
|
|
}
|
|
|
|
// GrantManagerAccess updates the form access level to "manager" allowing
|
|
// directly changing some system record fields (often used with auth collection records).
|
|
func (form *RecordUpsert) GrantManagerAccess() {
|
|
form.accessLevel = accessLevelManager
|
|
}
|
|
|
|
// GrantSuperuserAccess updates the form access level to "superuser" allowing
|
|
// directly changing all system record fields, including those marked as "Hidden".
|
|
func (form *RecordUpsert) GrantSuperuserAccess() {
|
|
form.accessLevel = accessLevelSuperuser
|
|
}
|
|
|
|
// HasManageAccess reports whether the form has "manager" or "superuser" level access.
|
|
func (form *RecordUpsert) HasManageAccess() bool {
|
|
return form.accessLevel == accessLevelManager || form.accessLevel == accessLevelSuperuser
|
|
}
|
|
|
|
// Load loads the provided data into the form and the related record.
|
|
func (form *RecordUpsert) Load(data map[string]any) {
|
|
excludeFields := []string{core.FieldNameExpand}
|
|
|
|
isAuth := form.record.Collection().IsAuth()
|
|
|
|
// load the special auth form fields
|
|
if isAuth {
|
|
if v, ok := data["password"]; ok {
|
|
form.Password = cast.ToString(v)
|
|
}
|
|
if v, ok := data["passwordConfirm"]; ok {
|
|
form.PasswordConfirm = cast.ToString(v)
|
|
}
|
|
if v, ok := data["oldPassword"]; ok {
|
|
form.OldPassword = cast.ToString(v)
|
|
}
|
|
|
|
excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
|
|
}
|
|
|
|
for k, v := range data {
|
|
if slices.Contains(excludeFields, k) {
|
|
continue
|
|
}
|
|
|
|
// set only known collection fields
|
|
field := form.record.SetIfFieldExists(k, v)
|
|
|
|
// restore original value if hidden field (with exception of the auth "password")
|
|
//
|
|
// note: this is an extra measure against loading hidden fields
|
|
// but usually is not used by the default route handlers since
|
|
// they filter additionally the data before calling Load
|
|
if form.accessLevel != accessLevelSuperuser && field != nil && field.GetHidden() && (!isAuth || field.GetName() != core.FieldNamePassword) {
|
|
form.record.SetRaw(field.GetName(), form.record.Original().GetRaw(field.GetName()))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (form *RecordUpsert) validateFormFields() error {
|
|
isAuth := form.record.Collection().IsAuth()
|
|
if !isAuth {
|
|
return nil
|
|
}
|
|
|
|
isNew := form.record.IsNew()
|
|
|
|
original := form.record.Original()
|
|
|
|
validateData := map[string]any{
|
|
"email": form.record.Email(),
|
|
"verified": form.record.Verified(),
|
|
"password": form.Password,
|
|
"passwordConfirm": form.PasswordConfirm,
|
|
"oldPassword": form.OldPassword,
|
|
}
|
|
|
|
return validation.Validate(validateData,
|
|
validation.Map(
|
|
validation.Key(
|
|
"email",
|
|
// don't allow direct email updates if the form doesn't have manage access permissions
|
|
// (aka. allow only admin or authorized auth models to directly update the field)
|
|
validation.When(
|
|
!isNew && !form.HasManageAccess(),
|
|
validation.By(validators.Equal(original.Email())),
|
|
),
|
|
),
|
|
validation.Key(
|
|
"verified",
|
|
// don't allow changing verified if the form doesn't have manage access permissions
|
|
// (aka. allow only admin or authorized auth models to directly change the field)
|
|
validation.When(
|
|
!form.HasManageAccess(),
|
|
validation.By(validators.Equal(original.Verified())),
|
|
),
|
|
),
|
|
validation.Key(
|
|
"password",
|
|
validation.When(
|
|
(isNew || form.PasswordConfirm != "" || form.OldPassword != ""),
|
|
validation.Required,
|
|
),
|
|
),
|
|
validation.Key(
|
|
"passwordConfirm",
|
|
validation.When(
|
|
(isNew || form.Password != "" || form.OldPassword != ""),
|
|
validation.Required,
|
|
),
|
|
validation.By(validators.Equal(form.Password)),
|
|
),
|
|
validation.Key(
|
|
"oldPassword",
|
|
// require old password only on update when:
|
|
// - form.HasManageAccess() is not satisfied
|
|
// - changing the existing password
|
|
validation.When(
|
|
!isNew && !form.HasManageAccess() && (form.Password != "" || form.PasswordConfirm != ""),
|
|
validation.Required,
|
|
validation.By(form.checkOldPassword),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
func (form *RecordUpsert) checkOldPassword(value any) error {
|
|
v, _ := value.(string)
|
|
if v == "" || form.record.IsNew() {
|
|
return nil // nothing to check
|
|
}
|
|
|
|
if !form.record.Original().ValidatePassword(v) {
|
|
return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// @todo consider removing and executing the Create API rule without dummy insert.
|
|
//
|
|
// DrySubmit performs a temp form submit within a transaction and reverts it at the end.
|
|
// For actual record persistence, check the [RecordUpsert.Submit()] method.
|
|
//
|
|
// This method doesn't perform validations, handle file uploads/deletes or trigger app save events!
|
|
func (form *RecordUpsert) DrySubmit(callback func(txApp core.App, drySavedRecord *core.Record) error) error {
|
|
isNew := form.record.IsNew()
|
|
|
|
clone := form.record.Clone()
|
|
|
|
// set an id if it doesn't have already
|
|
// (the value doesn't matter; it is used only during the manual delete/update rollback)
|
|
if clone.IsNew() && clone.Id == "" {
|
|
clone.Id = "_temp_" + security.PseudorandomString(15)
|
|
}
|
|
|
|
app := form.app.UnsafeWithoutHooks()
|
|
|
|
_, isTransactional := app.DB().(*dbx.Tx)
|
|
if !isTransactional {
|
|
return app.RunInTransaction(func(txApp core.App) error {
|
|
tx, ok := txApp.DB().(*dbx.Tx)
|
|
if !ok {
|
|
return errors.New("failed to get transaction db")
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if err := txApp.SaveNoValidateWithContext(form.ctx, clone); err != nil {
|
|
return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
|
|
}
|
|
|
|
if callback != nil {
|
|
return callback(txApp, clone)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// already in a transaction
|
|
// (manual rollback to avoid starting another transaction)
|
|
// ---------------------------------------------------------------
|
|
err := app.SaveNoValidateWithContext(form.ctx, clone)
|
|
if err != nil {
|
|
return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
|
|
}
|
|
|
|
manualRollback := func() error {
|
|
if isNew {
|
|
err = app.DeleteWithContext(form.ctx, clone)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to rollback dry submit created record: %w", err)
|
|
}
|
|
} else {
|
|
clone.Load(clone.Original().FieldsData())
|
|
err = app.SaveNoValidateWithContext(form.ctx, clone)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to rollback dry submit updated record: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if callback != nil {
|
|
return errors.Join(callback(app, clone), manualRollback())
|
|
}
|
|
|
|
return manualRollback()
|
|
}
|
|
|
|
// Submit validates the form specific validations and attempts to save the form record.
|
|
func (form *RecordUpsert) Submit() error {
|
|
err := form.validateFormFields()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// run record validations and persist in db
|
|
return form.app.SaveWithContext(form.ctx, form.record)
|
|
}
|