1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-01-27 23:46:18 +02:00
pocketbase/forms/record_upsert.go
2024-09-29 21:09:46 +03:00

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)
}