diff --git a/CHANGELOG.md b/CHANGELOG.md index 327687f7..0f99d5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ app.OnRealtimeDisconnectRequest() app.OnRealtimeBeforeMessageSend() app.OnRealtimeAfterMessageSend() + app.OnRecordBeforeRequestPasswordResetRequest() + app.OnRecordAfterRequestPasswordResetRequest() + app.OnRecordBeforeConfirmPasswordResetRequest() + app.OnRecordAfterConfirmPasswordResetRequest() + app.OnRecordBeforeRequestVerificationRequest() + app.OnRecordAfterRequestVerificationRequest() + app.OnRecordBeforeConfirmVerificationRequest() + app.OnRecordAfterConfirmVerificationRequest() + app.OnRecordBeforeRequestEmailChangeRequest() + app.OnRecordAfterRequestEmailChangeRequest() + app.OnRecordBeforeConfirmEmailChangeRequest() + app.OnRecordAfterConfirmEmailChangeRequest() ``` - Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)). diff --git a/apis/base.go b/apis/base.go index 33071cf3..5aee0914 100644 --- a/apis/base.go +++ b/apis/base.go @@ -69,10 +69,10 @@ func InitApi(app core.App) (*echo.Echo, error) { Error: apiErr, } + // send error response hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error { - // Send response + // @see https://github.com/labstack/echo/issues/608 if e.HttpContext.Request().Method == http.MethodHead { - // @see https://github.com/labstack/echo/issues/608 return e.HttpContext.NoContent(apiErr.Code) } diff --git a/apis/record_auth.go b/apis/record_auth.go index d4c7574c..dcd7a772 100644 --- a/apis/record_auth.go +++ b/apis/record_auth.go @@ -283,15 +283,39 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error { return NewBadRequestError("An error occurred while validating the form.", err) } - // run in background because we don't need to show - // the result to the user (prevents users enumeration) - routine.FireAndForget(func() { - if err := form.Submit(); err != nil && api.app.IsDebug() { - log.Println(err) + event := &core.RecordRequestPasswordResetEvent{ + HttpContext: c, + } + + submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + event.Record = record + + return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error { + // run in background because we don't need to show the result to the client + routine.FireAndForget(func() { + if err := next(e.Record); err != nil && api.app.IsDebug() { + log.Println(err) + } + }) + + return e.HttpContext.NoContent(http.StatusNoContent) + }) } }) - return c.NoContent(http.StatusNoContent) + if submitErr == nil { + api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event) + } else if api.app.IsDebug() { + log.Println(submitErr) + } + + // don't return the response error to prevent emails enumeration + if !c.Response().Committed { + c.NoContent(http.StatusNoContent) + } + + return nil } func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error { @@ -305,12 +329,29 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error { return NewBadRequestError("An error occurred while loading the submitted data.", readErr) } - _, submitErr := form.Submit() - if submitErr != nil { - return NewBadRequestError("Failed to set new password.", submitErr) + event := &core.RecordConfirmPasswordResetEvent{ + HttpContext: c, } - return c.NoContent(http.StatusNoContent) + _, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + event.Record = record + + return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error { + if err := next(e.Record); err != nil { + return NewBadRequestError("Failed to set new password.", err) + } + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + } + }) + + if submitErr == nil { + api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event) + } + + return submitErr } func (api *recordAuthApi) requestVerification(c echo.Context) error { @@ -328,15 +369,39 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error { return NewBadRequestError("An error occurred while validating the form.", err) } - // run in background because we don't need to show - // the result to the user (prevents users enumeration) - routine.FireAndForget(func() { - if err := form.Submit(); err != nil && api.app.IsDebug() { - log.Println(err) + event := &core.RecordRequestVerificationEvent{ + HttpContext: c, + } + + submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + event.Record = record + + return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error { + // run in background because we don't need to show the result to the client + routine.FireAndForget(func() { + if err := next(e.Record); err != nil && api.app.IsDebug() { + log.Println(err) + } + }) + + return e.HttpContext.NoContent(http.StatusNoContent) + }) } }) - return c.NoContent(http.StatusNoContent) + if submitErr == nil { + api.app.OnRecordAfterRequestVerificationRequest().Trigger(event) + } else if api.app.IsDebug() { + log.Println(submitErr) + } + + // don't return the response error to prevent emails enumeration + if !c.Response().Committed { + c.NoContent(http.StatusNoContent) + } + + return nil } func (api *recordAuthApi) confirmVerification(c echo.Context) error { @@ -350,12 +415,29 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error { return NewBadRequestError("An error occurred while loading the submitted data.", readErr) } - _, submitErr := form.Submit() - if submitErr != nil { - return NewBadRequestError("An error occurred while submitting the form.", submitErr) + event := &core.RecordConfirmVerificationEvent{ + HttpContext: c, } - return c.NoContent(http.StatusNoContent) + _, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + event.Record = record + + return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error { + if err := next(e.Record); err != nil { + return NewBadRequestError("An error occurred while submitting the form.", err) + } + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + } + }) + + if submitErr == nil { + api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event) + } + + return submitErr } func (api *recordAuthApi) requestEmailChange(c echo.Context) error { @@ -369,11 +451,28 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error { return NewBadRequestError("An error occurred while loading the submitted data.", err) } - if err := form.Submit(); err != nil { - return NewBadRequestError("Failed to request email change.", err) + event := &core.RecordRequestEmailChangeEvent{ + HttpContext: c, + Record: record, } - return c.NoContent(http.StatusNoContent) + submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error { + if err := next(e.Record); err != nil { + return NewBadRequestError("Failed to request email change.", err) + } + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + } + }) + + if submitErr == nil { + api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event) + } + + return submitErr } func (api *recordAuthApi) confirmEmailChange(c echo.Context) error { @@ -387,12 +486,29 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error { return NewBadRequestError("An error occurred while loading the submitted data.", readErr) } - _, submitErr := form.Submit() - if submitErr != nil { - return NewBadRequestError("Failed to confirm email change.", submitErr) + event := &core.RecordConfirmEmailChangeEvent{ + HttpContext: c, } - return c.NoContent(http.StatusNoContent) + _, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + event.Record = record + + return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error { + if err := next(e.Record); err != nil { + return NewBadRequestError("Failed to confirm email change.", err) + } + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + } + }) + + if submitErr == nil { + api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event) + } + + return submitErr } func (api *recordAuthApi) listExternalAuths(c echo.Context) error { diff --git a/apis/record_auth_test.go b/apis/record_auth_test.go index 53aa822c..62cf521f 100644 --- a/apis/record_auth_test.go +++ b/apis/record_auth_test.go @@ -346,10 +346,12 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) { Delay: 100 * time.Millisecond, ExpectedStatus: 204, ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnMailerBeforeRecordResetPasswordSend": 1, - "OnMailerAfterRecordResetPasswordSend": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnRecordBeforeRequestPasswordResetRequest": 1, + "OnRecordAfterRequestPasswordResetRequest": 1, + "OnMailerBeforeRecordResetPasswordSend": 1, + "OnMailerAfterRecordResetPasswordSend": 1, }, }, { @@ -466,8 +468,10 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) { }`), ExpectedStatus: 204, ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordBeforeConfirmPasswordResetRequest": 1, + "OnRecordAfterConfirmPasswordResetRequest": 1, }, }, } @@ -518,6 +522,10 @@ func TestRecordAuthRequestVerification(t *testing.T) { Body: strings.NewReader(`{"email":"test2@example.com"}`), Delay: 100 * time.Millisecond, ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRecordBeforeRequestVerificationRequest": 1, + "OnRecordAfterRequestVerificationRequest": 1, + }, }, { Name: "existing auth record", @@ -527,10 +535,12 @@ func TestRecordAuthRequestVerification(t *testing.T) { Delay: 100 * time.Millisecond, ExpectedStatus: 204, ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnMailerBeforeRecordVerificationSend": 1, - "OnMailerAfterRecordVerificationSend": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnRecordBeforeRequestVerificationRequest": 1, + "OnRecordAfterRequestVerificationRequest": 1, + "OnMailerBeforeRecordVerificationSend": 1, + "OnMailerAfterRecordVerificationSend": 1, }, }, { @@ -540,6 +550,10 @@ func TestRecordAuthRequestVerification(t *testing.T) { Body: strings.NewReader(`{"email":"test@example.com"}`), Delay: 100 * time.Millisecond, ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + // "OnRecordBeforeRequestVerificationRequest": 1, + // "OnRecordAfterRequestVerificationRequest": 1, + }, BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { // simulate recent verification sent authRecord, err := app.Dao().FindFirstRecordByData("users", "email", "test@example.com") @@ -627,8 +641,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) { }`), ExpectedStatus: 204, ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordBeforeConfirmVerificationRequest": 1, + "OnRecordAfterConfirmVerificationRequest": 1, }, }, { @@ -639,7 +655,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) { "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg" }`), ExpectedStatus: 204, - ExpectedEvents: map[string]int{}, + ExpectedEvents: map[string]int{ + "OnRecordBeforeConfirmVerificationRequest": 1, + "OnRecordAfterConfirmVerificationRequest": 1, + }, }, { Name: "valid verification token from a collection without allowed login", @@ -651,8 +670,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) { ExpectedStatus: 204, ExpectedContent: []string{}, ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordBeforeConfirmVerificationRequest": 1, + "OnRecordAfterConfirmVerificationRequest": 1, }, }, } @@ -751,8 +772,10 @@ func TestRecordAuthRequestEmailChange(t *testing.T) { }, ExpectedStatus: 204, ExpectedEvents: map[string]int{ - "OnMailerBeforeRecordChangeEmailSend": 1, - "OnMailerAfterRecordChangeEmailSend": 1, + "OnMailerBeforeRecordChangeEmailSend": 1, + "OnMailerAfterRecordChangeEmailSend": 1, + "OnRecordBeforeRequestEmailChangeRequest": 1, + "OnRecordAfterRequestEmailChangeRequest": 1, }, }, } @@ -833,8 +856,10 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) { }`), ExpectedStatus: 204, ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + "OnRecordBeforeConfirmEmailChangeRequest": 1, + "OnRecordAfterConfirmEmailChangeRequest": 1, }, }, { diff --git a/core/app.go b/core/app.go index 4efb4a20..295e9e2d 100644 --- a/core/app.go +++ b/core/app.go @@ -298,7 +298,7 @@ type App interface { OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] // --------------------------------------------------------------- - // Auth Record API event hooks + // Record Auth API event hooks // --------------------------------------------------------------- // OnRecordAuthRequest hook is triggered on each successful API @@ -308,6 +308,72 @@ type App interface { // record data and token. OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] + // OnRecordBeforeRequestPasswordResetRequest hook is triggered before each API Record + // request password reset request (after request data load and before sending the reset email). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning [hook.StopPropagation]). + OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] + + // OnRecordAfterRequestPasswordResetRequest hook is triggered after each + // successful API request password reset request. + OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] + + // OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each API Record + // confirm password reset request (after request data load and before persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning [hook.StopPropagation]). + OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] + + // OnRecordAfterConfirmPasswordResetRequest hook is triggered after each + // successful API confirm password reset request. + OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] + + // OnRecordBeforeRequestVerificationRequest hook is triggered before each API Record + // request verification request (after request data load and before sending the verification email). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning [hook.StopPropagation]). + OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] + + // OnRecordAfterRequestVerificationRequest hook is triggered after each + // successful API request verification request. + OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] + + // OnRecordBeforeConfirmVerificationRequest hook is triggered before each API Record + // confirm verification request (after request data load and before persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning [hook.StopPropagation]). + OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] + + // OnRecordAfterConfirmVerificationRequest hook is triggered after each + // successful API confirm verification request. + OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] + + // OnRecordBeforeRequestEmailChangeRequest hook is triggered before each API Record request email change request + // (after request data load and before sending the email change confirmation email). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning [hook.StopPropagation]). + OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] + + // OnRecordAfterRequestEmailChangeRequest hook is triggered after each + // successful API request email change request. + OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] + + // OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each API Record + // confirm email change request (after request data load and before persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning [hook.StopPropagation]). + OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] + + // OnRecordAfterConfirmEmailChangeRequest hook is triggered after each + // successful API confirm email change request. + OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] + // OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request. // // Could be used to validate or modify the response before returning it to the client. @@ -325,7 +391,7 @@ type App interface { OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] // --------------------------------------------------------------- - // Record API event hooks + // Record CRUD API event hooks // --------------------------------------------------------------- // OnRecordsListRequest hook is triggered on each API Records list request. diff --git a/core/base.go b/core/base.go index ff4f4e4e..fbdad166 100644 --- a/core/base.go +++ b/core/base.go @@ -91,13 +91,25 @@ type BaseApp struct { onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent] onAdminAuthRequest *hook.Hook[*AdminAuthEvent] - // user api event hooks - onRecordAuthRequest *hook.Hook[*RecordAuthEvent] - onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent] - onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] - onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] + // record auth API event hooks + onRecordAuthRequest *hook.Hook[*RecordAuthEvent] + onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent] + onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent] + onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent] + onRecordAfterConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent] + onRecordBeforeRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent] + onRecordAfterRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent] + onRecordBeforeConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent] + onRecordAfterConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent] + onRecordBeforeRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent] + onRecordAfterRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent] + onRecordBeforeConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent] + onRecordAfterConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent] + onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent] + onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] + onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] - // record api event hooks + // record crud API event hooks onRecordsListRequest *hook.Hook[*RecordsListEvent] onRecordViewRequest *hook.Hook[*RecordViewEvent] onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent] @@ -107,7 +119,7 @@ type BaseApp struct { onRecordBeforeDeleteRequest *hook.Hook[*RecordDeleteEvent] onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent] - // collection api event hooks + // collection API event hooks onCollectionsListRequest *hook.Hook[*CollectionsListEvent] onCollectionViewRequest *hook.Hook[*CollectionViewEvent] onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent] @@ -185,13 +197,25 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp { onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{}, onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{}, - // user API event hooks - onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{}, - onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{}, - onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, - onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, + // record auth API event hooks + onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{}, + onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{}, + onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{}, + onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{}, + onRecordAfterConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{}, + onRecordBeforeRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{}, + onRecordAfterRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{}, + onRecordBeforeConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{}, + onRecordAfterConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{}, + onRecordBeforeRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{}, + onRecordAfterRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{}, + onRecordBeforeConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{}, + onRecordAfterConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{}, + onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{}, + onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, + onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, - // record API event hooks + // record crud API event hooks onRecordsListRequest: &hook.Hook[*RecordsListEvent]{}, onRecordViewRequest: &hook.Hook[*RecordViewEvent]{}, onRecordBeforeCreateRequest: &hook.Hook[*RecordCreateEvent]{}, @@ -574,13 +598,61 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] { } // ------------------------------------------------------------------- -// Auth Record API event hooks +// Record auth API event hooks // ------------------------------------------------------------------- func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] { return app.onRecordAuthRequest } +func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] { + return app.onRecordBeforeRequestPasswordResetRequest +} + +func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] { + return app.onRecordAfterRequestPasswordResetRequest +} + +func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] { + return app.onRecordBeforeConfirmPasswordResetRequest +} + +func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] { + return app.onRecordAfterConfirmPasswordResetRequest +} + +func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] { + return app.onRecordBeforeRequestVerificationRequest +} + +func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] { + return app.onRecordAfterRequestVerificationRequest +} + +func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] { + return app.onRecordBeforeConfirmVerificationRequest +} + +func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] { + return app.onRecordAfterConfirmVerificationRequest +} + +func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] { + return app.onRecordBeforeRequestEmailChangeRequest +} + +func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] { + return app.onRecordAfterRequestEmailChangeRequest +} + +func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] { + return app.onRecordBeforeConfirmEmailChangeRequest +} + +func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] { + return app.onRecordAfterConfirmEmailChangeRequest +} + func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] { return app.onRecordListExternalAuthsRequest } @@ -594,7 +666,7 @@ func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordU } // ------------------------------------------------------------------- -// Record API event hooks +// Record CRUD API event hooks // ------------------------------------------------------------------- func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] { diff --git a/core/events.go b/core/events.go index 25f8a7c5..62bf968d 100644 --- a/core/events.go +++ b/core/events.go @@ -99,7 +99,7 @@ type SettingsUpdateEvent struct { } // ------------------------------------------------------------------- -// Record API events data +// Record CRUD API events data // ------------------------------------------------------------------- type RecordsListEvent struct { @@ -129,6 +129,59 @@ type RecordDeleteEvent struct { Record *models.Record } +// ------------------------------------------------------------------- +// Auth Record API events data +// ------------------------------------------------------------------- + +type RecordAuthEvent struct { + HttpContext echo.Context + Record *models.Record + Token string + Meta any +} + +type RecordRequestPasswordResetEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordConfirmPasswordResetEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordRequestVerificationEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordConfirmVerificationEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordRequestEmailChangeEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordConfirmEmailChangeEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordListExternalAuthsEvent struct { + HttpContext echo.Context + Record *models.Record + ExternalAuths []*models.ExternalAuth +} + +type RecordUnlinkExternalAuthEvent struct { + HttpContext echo.Context + Record *models.Record + ExternalAuth *models.ExternalAuth +} + // ------------------------------------------------------------------- // Admin API events data // ------------------------------------------------------------------- @@ -165,29 +218,6 @@ type AdminAuthEvent struct { Token string } -// ------------------------------------------------------------------- -// Auth Record API events data -// ------------------------------------------------------------------- - -type RecordAuthEvent struct { - HttpContext echo.Context - Record *models.Record - Token string - Meta any -} - -type RecordListExternalAuthsEvent struct { - HttpContext echo.Context - Record *models.Record - ExternalAuths []*models.ExternalAuth -} - -type RecordUnlinkExternalAuthEvent struct { - HttpContext echo.Context - Record *models.Record - ExternalAuth *models.ExternalAuth -} - // ------------------------------------------------------------------- // Collection API events data // ------------------------------------------------------------------- diff --git a/forms/base.go b/forms/base.go index 46c2251f..698feaa1 100644 --- a/forms/base.go +++ b/forms/base.go @@ -4,6 +4,8 @@ package forms import ( "regexp" + + "github.com/pocketbase/pocketbase/models" ) // base ID value regex pattern @@ -13,7 +15,8 @@ var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`) // Usually used in combination with InterceptorFunc. type InterceptorNextFunc = func() error -// InterceptorFunc defines a single interceptor function that will execute the provided next func handler. +// InterceptorFunc defines a single interceptor function that +// will execute the provided next func handler. type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc // runInterceptors executes the provided list of interceptors. @@ -21,6 +24,21 @@ func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) for i := len(interceptors) - 1; i >= 0; i-- { next = interceptors[i](next) } - return next() } + +// InterceptorWithRecordNextFunc is a Record interceptor handler function. +// Usually used in combination with InterceptorWithRecordFunc. +type InterceptorWithRecordNextFunc = func(record *models.Record) error + +// InterceptorWithRecordFunc defines a single Record interceptor function +// that will execute the provided next func handler. +type InterceptorWithRecordFunc func(next InterceptorWithRecordNextFunc) InterceptorWithRecordNextFunc + +// runInterceptorsWithRecord executes the provided list of Record interceptors. +func runInterceptorsWithRecord(record *models.Record, next InterceptorWithRecordNextFunc, interceptors ...InterceptorWithRecordFunc) error { + for i := len(interceptors) - 1; i >= 0; i-- { + next = interceptors[i](next) + } + return next(record) +} diff --git a/forms/collection_upsert_test.go b/forms/collection_upsert_test.go index 1e07ece9..adbe1c77 100644 --- a/forms/collection_upsert_test.go +++ b/forms/collection_upsert_test.go @@ -367,12 +367,12 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) { } // check interceptor calls - expectInterceptorCall := 1 + expectInterceptorCalls := 1 if len(s.expectedErrors) > 0 { - expectInterceptorCall = 0 + expectInterceptorCalls = 0 } - if interceptorCalls != expectInterceptorCall { - t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCall, interceptorCalls) + if interceptorCalls != expectInterceptorCalls { + t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls) } // check errors diff --git a/forms/record_email_change_confirm.go b/forms/record_email_change_confirm.go index f4712299..033252d8 100644 --- a/forms/record_email_change_confirm.go +++ b/forms/record_email_change_confirm.go @@ -113,7 +113,10 @@ func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, // Submit validates and submits the auth record email change confirmation form. // On success returns the updated auth record associated to `form.Token`. -func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) { +// +// You can optionally provide a list of InterceptorWithRecordFunc to +// further modify the form behavior before persisting it. +func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) { if err := form.Validate(); err != nil { return nil, err } @@ -127,8 +130,12 @@ func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) { authRecord.SetVerified(true) authRecord.RefreshTokenKey() // invalidate old tokens - if err := form.dao.SaveRecord(authRecord); err != nil { - return nil, err + interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error { + return form.dao.SaveRecord(m) + }, interceptors...) + + if interceptorsErr != nil { + return nil, interceptorsErr } return authRecord, nil diff --git a/forms/record_email_change_confirm_test.go b/forms/record_email_change_confirm_test.go index 5d6ee1d1..96152282 100644 --- a/forms/record_email_change_confirm_test.go +++ b/forms/record_email_change_confirm_test.go @@ -2,10 +2,12 @@ package forms_test import ( "encoding/json" + "errors" "testing" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/security" ) @@ -82,7 +84,24 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) { continue } - record, err := form.Submit() + interceptorCalls := 0 + interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(r *models.Record) error { + interceptorCalls++ + return next(r) + } + } + + record, err := form.Submit(interceptor) + + // check interceptor calls + expectInterceptorCalls := 1 + if len(s.expectedErrors) > 0 { + expectInterceptorCalls = 0 + } + if interceptorCalls != expectInterceptorCalls { + t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) + } // parse errors errs, ok := err.(validation.Errors) @@ -124,3 +143,58 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) { } } } + +func TestRecordEmailChangeConfirmInterceptors(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + + authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordEmailChangeConfirm(testApp, authCollection) + form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY" + form.Password = "1234567890" + interceptorEmail := authRecord.Email() + testErr := errors.New("test_error") + + interceptor1Called := false + interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptor1Called = true + return next(record) + } + } + + interceptor2Called := false + interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptorEmail = record.Email() + interceptor2Called = true + return testErr + } + } + + _, submitErr := form.Submit(interceptor1, interceptor2) + if submitErr != testErr { + t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) + } + + if !interceptor1Called { + t.Fatalf("Expected interceptor1 to be called") + } + + if !interceptor2Called { + t.Fatalf("Expected interceptor2 to be called") + } + + if interceptorEmail == authRecord.Email() { + t.Fatalf("Expected the form model to be filled before calling the interceptors") + } +} diff --git a/forms/record_email_change_request.go b/forms/record_email_change_request.go index 8c655f7e..8e77932c 100644 --- a/forms/record_email_change_request.go +++ b/forms/record_email_change_request.go @@ -61,10 +61,15 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error { } // Submit validates and sends the change email request. -func (form *RecordEmailChangeRequest) Submit() error { +// +// You can optionally provide a list of InterceptorWithRecordFunc to +// further modify the form behavior before persisting it. +func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error { if err := form.Validate(); err != nil { return err } - return mails.SendRecordChangeEmail(form.app, form.record, form.NewEmail) + return runInterceptorsWithRecord(form.record, func(m *models.Record) error { + return mails.SendRecordChangeEmail(form.app, m, form.NewEmail) + }, interceptors...) } diff --git a/forms/record_email_change_request_test.go b/forms/record_email_change_request_test.go index af364f07..daec3ffd 100644 --- a/forms/record_email_change_request_test.go +++ b/forms/record_email_change_request_test.go @@ -2,10 +2,12 @@ package forms_test import ( "encoding/json" + "errors" "testing" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tests" ) @@ -57,7 +59,24 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) { continue } - err := form.Submit() + interceptorCalls := 0 + interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(r *models.Record) error { + interceptorCalls++ + return next(r) + } + } + + err := form.Submit(interceptor) + + // check interceptor calls + expectInterceptorCalls := 1 + if len(s.expectedErrors) > 0 { + expectInterceptorCalls = 0 + } + if interceptorCalls != expectInterceptorCalls { + t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) + } // parse errors errs, ok := err.(validation.Errors) @@ -85,3 +104,46 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) { } } } + +func TestRecordEmailChangeRequestInterceptors(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordEmailChangeRequest(testApp, authRecord) + form.NewEmail = "test_new@example.com" + testErr := errors.New("test_error") + + interceptor1Called := false + interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptor1Called = true + return next(record) + } + } + + interceptor2Called := false + interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptor2Called = true + return testErr + } + } + + submitErr := form.Submit(interceptor1, interceptor2) + if submitErr != testErr { + t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) + } + + if !interceptor1Called { + t.Fatalf("Expected interceptor1 to be called") + } + + if !interceptor2Called { + t.Fatalf("Expected interceptor2 to be called") + } +} diff --git a/forms/record_password_reset_confirm.go b/forms/record_password_reset_confirm.go index a3ca1086..89722c5d 100644 --- a/forms/record_password_reset_confirm.go +++ b/forms/record_password_reset_confirm.go @@ -71,7 +71,10 @@ func (form *RecordPasswordResetConfirm) checkToken(value any) error { // Submit validates and submits the form. // On success returns the updated auth record associated to `form.Token`. -func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) { +// +// You can optionally provide a list of InterceptorWithRecordFunc to +// further modify the form behavior before persisting it. +func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) { if err := form.Validate(); err != nil { return nil, err } @@ -88,8 +91,12 @@ func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) { return nil, err } - if err := form.dao.SaveRecord(authRecord); err != nil { - return nil, err + interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error { + return form.dao.SaveRecord(m) + }, interceptors...) + + if interceptorsErr != nil { + return nil, interceptorsErr } return authRecord, nil diff --git a/forms/record_password_reset_confirm_test.go b/forms/record_password_reset_confirm_test.go index c3ef5466..543a9c9c 100644 --- a/forms/record_password_reset_confirm_test.go +++ b/forms/record_password_reset_confirm_test.go @@ -2,10 +2,12 @@ package forms_test import ( "encoding/json" + "errors" "testing" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/security" ) @@ -76,7 +78,15 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) { continue } - record, submitErr := form.Submit() + interceptorCalls := 0 + interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(r *models.Record) error { + interceptorCalls++ + return next(r) + } + } + + record, submitErr := form.Submit(interceptor) // parse errors errs, ok := submitErr.(validation.Errors) @@ -85,6 +95,15 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) { continue } + // check interceptor calls + expectInterceptorCalls := 1 + if len(s.expectedErrors) > 0 { + expectInterceptorCalls = 0 + } + if interceptorCalls != expectInterceptorCalls { + t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) + } + // check errors if len(errs) > len(s.expectedErrors) { t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) @@ -115,3 +134,59 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) { } } } + +func TestRecordPasswordResetConfirmInterceptors(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + + authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordPasswordResetConfirm(testApp, authCollection) + form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg" + form.Password = "1234567890" + form.PasswordConfirm = "1234567890" + interceptorTokenKey := authRecord.TokenKey() + testErr := errors.New("test_error") + + interceptor1Called := false + interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptor1Called = true + return next(record) + } + } + + interceptor2Called := false + interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptorTokenKey = record.TokenKey() + interceptor2Called = true + return testErr + } + } + + _, submitErr := form.Submit(interceptor1, interceptor2) + if submitErr != testErr { + t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) + } + + if !interceptor1Called { + t.Fatalf("Expected interceptor1 to be called") + } + + if !interceptor2Called { + t.Fatalf("Expected interceptor2 to be called") + } + + if interceptorTokenKey == authRecord.TokenKey() { + t.Fatalf("Expected the form model to be filled before calling the interceptors") + } +} diff --git a/forms/record_password_reset_request.go b/forms/record_password_reset_request.go index bbba8cff..9b5c4d1a 100644 --- a/forms/record_password_reset_request.go +++ b/forms/record_password_reset_request.go @@ -59,12 +59,15 @@ func (form *RecordPasswordResetRequest) Validate() error { // Submit validates and submits the form. // On success, sends a password reset email to the `form.Email` auth record. -func (form *RecordPasswordResetRequest) Submit() error { +// +// You can optionally provide a list of InterceptorWithRecordFunc to +// further modify the form behavior before persisting it. +func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) error { if err := form.Validate(); err != nil { return err } - authRecord, err := form.dao.FindFirstRecordByData(form.collection.Id, schema.FieldNameEmail, form.Email) + authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email) if err != nil { return err } @@ -75,12 +78,14 @@ func (form *RecordPasswordResetRequest) Submit() error { return errors.New("You've already requested a password reset.") } - if err := mails.SendRecordPasswordReset(form.app, authRecord); err != nil { - return err - } - // update last sent timestamp authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime()) - return form.dao.SaveRecord(authRecord) + return runInterceptorsWithRecord(authRecord, func(m *models.Record) error { + if err := mails.SendRecordPasswordReset(form.app, m); err != nil { + return err + } + + return form.dao.SaveRecord(m) + }, interceptors...) } diff --git a/forms/record_password_reset_request_test.go b/forms/record_password_reset_request_test.go index b6413887..ff0db1fa 100644 --- a/forms/record_password_reset_request_test.go +++ b/forms/record_password_reset_request_test.go @@ -2,10 +2,12 @@ package forms_test import ( "encoding/json" + "errors" "testing" "time" "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/types" ) @@ -64,7 +66,24 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) { continue } - err := form.Submit() + interceptorCalls := 0 + interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(r *models.Record) error { + interceptorCalls++ + return next(r) + } + } + + err := form.Submit(interceptor) + + // check interceptor calls + expectInterceptorCalls := 1 + if s.expectError { + expectInterceptorCalls = 0 + } + if interceptorCalls != expectInterceptorCalls { + t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) + } hasErr := err != nil if hasErr != s.expectError { @@ -95,3 +114,57 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) { } } } + +func TestRecordPasswordResetRequestInterceptors(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + + authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordPasswordResetRequest(testApp, authCollection) + form.Email = authRecord.Email() + interceptorLastResetSentAt := authRecord.LastResetSentAt() + testErr := errors.New("test_error") + + interceptor1Called := false + interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptor1Called = true + return next(record) + } + } + + interceptor2Called := false + interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptorLastResetSentAt = record.LastResetSentAt() + interceptor2Called = true + return testErr + } + } + + submitErr := form.Submit(interceptor1, interceptor2) + if submitErr != testErr { + t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) + } + + if !interceptor1Called { + t.Fatalf("Expected interceptor1 to be called") + } + + if !interceptor2Called { + t.Fatalf("Expected interceptor2 to be called") + } + + if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() { + t.Fatalf("Expected the form model to be filled before calling the interceptors") + } +} diff --git a/forms/record_verification_confirm.go b/forms/record_verification_confirm.go index 87bab326..a4f15f46 100644 --- a/forms/record_verification_confirm.go +++ b/forms/record_verification_confirm.go @@ -76,7 +76,10 @@ func (form *RecordVerificationConfirm) checkToken(value any) error { // Submit validates and submits the form. // On success returns the verified auth record associated to `form.Token`. -func (form *RecordVerificationConfirm) Submit() (*models.Record, error) { +// +// You can optionally provide a list of InterceptorWithRecordFunc to +// further modify the form behavior before persisting it. +func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) { if err := form.Validate(); err != nil { return nil, err } @@ -89,14 +92,22 @@ func (form *RecordVerificationConfirm) Submit() (*models.Record, error) { return nil, err } - if record.Verified() { - return record, nil // already verified + wasVerified := record.Verified() + + if !wasVerified { + record.SetVerified(true) } - record.SetVerified(true) + interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error { + if wasVerified { + return nil // already verified + } - if err := form.dao.SaveRecord(record); err != nil { - return nil, err + return form.dao.SaveRecord(m) + }, interceptors...) + + if interceptorsErr != nil { + return nil, interceptorsErr } return record, nil diff --git a/forms/record_verification_confirm_test.go b/forms/record_verification_confirm_test.go index 7059a58a..d927f5b7 100644 --- a/forms/record_verification_confirm_test.go +++ b/forms/record_verification_confirm_test.go @@ -2,9 +2,11 @@ package forms_test import ( "encoding/json" + "errors" "testing" "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/security" ) @@ -54,7 +56,24 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) { continue } - record, err := form.Submit() + interceptorCalls := 0 + interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(r *models.Record) error { + interceptorCalls++ + return next(r) + } + } + + record, err := form.Submit(interceptor) + + // check interceptor calls + expectInterceptorCalls := 1 + if s.expectError { + expectInterceptorCalls = 0 + } + if interceptorCalls != expectInterceptorCalls { + t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) + } hasErr := err != nil if hasErr != s.expectError { @@ -77,3 +96,57 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) { } } } + +func TestRecordVerificationConfirmInterceptors(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + + authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordVerificationConfirm(testApp, authCollection) + form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc" + interceptorVerified := authRecord.Verified() + testErr := errors.New("test_error") + + interceptor1Called := false + interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptor1Called = true + return next(record) + } + } + + interceptor2Called := false + interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptorVerified = record.Verified() + interceptor2Called = true + return testErr + } + } + + _, submitErr := form.Submit(interceptor1, interceptor2) + if submitErr != testErr { + t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) + } + + if !interceptor1Called { + t.Fatalf("Expected interceptor1 to be called") + } + + if !interceptor2Called { + t.Fatalf("Expected interceptor2 to be called") + } + + if interceptorVerified == authRecord.Verified() { + t.Fatalf("Expected the form model to be filled before calling the interceptors") + } +} diff --git a/forms/record_verification_request.go b/forms/record_verification_request.go index d702e936..3868d610 100644 --- a/forms/record_verification_request.go +++ b/forms/record_verification_request.go @@ -59,7 +59,10 @@ func (form *RecordVerificationRequest) Validate() error { // Submit validates and sends a verification request email // to the `form.Email` auth record. -func (form *RecordVerificationRequest) Submit() error { +// +// You can optionally provide a list of InterceptorWithRecordFunc to +// further modify the form behavior before persisting it. +func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRecordFunc) error { if err := form.Validate(); err != nil { return err } @@ -73,22 +76,26 @@ func (form *RecordVerificationRequest) Submit() error { return err } - if record.GetBool(schema.FieldNameVerified) { - return nil // already verified + if !record.Verified() { + now := time.Now().UTC() + lastVerificationSentAt := record.LastVerificationSentAt().Time() + if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold { + return errors.New("A verification email was already sent.") + } + + // update last sent timestamp + record.SetLastVerificationSentAt(types.NowDateTime()) } - now := time.Now().UTC() - lastVerificationSentAt := record.LastVerificationSentAt().Time() - if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold { - return errors.New("A verification email was already sent.") - } + return runInterceptorsWithRecord(record, func(m *models.Record) error { + if m.Verified() { + return nil // already verified + } - if err := mails.SendRecordVerification(form.app, record); err != nil { - return err - } + if err := mails.SendRecordVerification(form.app, m); err != nil { + return err + } - // update last sent timestamp - record.Set(schema.FieldNameLastVerificationSentAt, types.NowDateTime()) - - return form.dao.SaveRecord(record) + return form.dao.SaveRecord(m) + }, interceptors...) } diff --git a/forms/record_verification_request_test.go b/forms/record_verification_request_test.go index b0ce7166..82a6dc25 100644 --- a/forms/record_verification_request_test.go +++ b/forms/record_verification_request_test.go @@ -2,10 +2,12 @@ package forms_test import ( "encoding/json" + "errors" "testing" "time" "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/types" ) @@ -78,15 +80,32 @@ func TestRecordVerificationRequestSubmit(t *testing.T) { // load data loadErr := json.Unmarshal([]byte(s.jsonData), form) if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + t.Errorf("[%d] Failed to load form data: %v", i, loadErr) continue } - err := form.Submit() + interceptorCalls := 0 + interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(r *models.Record) error { + interceptorCalls++ + return next(r) + } + } + + err := form.Submit(interceptor) + + // check interceptor calls + expectInterceptorCalls := 1 + if s.expectError { + expectInterceptorCalls = 0 + } + if interceptorCalls != expectInterceptorCalls { + t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) + } hasErr := err != nil if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) } expectedMails := 0 @@ -94,7 +113,7 @@ func TestRecordVerificationRequestSubmit(t *testing.T) { expectedMails = 1 } if testApp.TestMailer.TotalSend != expectedMails { - t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) + t.Errorf("[%d] Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) } if s.expectError { @@ -103,13 +122,67 @@ func TestRecordVerificationRequestSubmit(t *testing.T) { user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email) if err != nil { - t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email) + 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()) + t.Errorf("[%d] Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt()) } } } + +func TestRecordVerificationRequestInterceptors(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } + + authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordVerificationRequest(testApp, authCollection) + form.Email = authRecord.Email() + interceptorLastVerificationSentAt := authRecord.LastVerificationSentAt() + testErr := errors.New("test_error") + + interceptor1Called := false + interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptor1Called = true + return next(record) + } + } + + interceptor2Called := false + interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { + return func(record *models.Record) error { + interceptorLastVerificationSentAt = record.LastVerificationSentAt() + interceptor2Called = true + return testErr + } + } + + submitErr := form.Submit(interceptor1, interceptor2) + if submitErr != testErr { + t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) + } + + if !interceptor1Called { + t.Fatalf("Expected interceptor1 to be called") + } + + if !interceptor2Called { + t.Fatalf("Expected interceptor2 to be called") + } + + if interceptorLastVerificationSentAt.String() == authRecord.LastVerificationSentAt().String() { + t.Fatalf("Expected the form model to be filled before calling the interceptors") + } +} diff --git a/tests/app.go b/tests/app.go index 4005a1cc..71744301 100644 --- a/tests/app.go +++ b/tests/app.go @@ -172,6 +172,66 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) { return nil }) + t.OnRecordBeforeRequestPasswordResetRequest().Add(func(e *core.RecordRequestPasswordResetEvent) error { + t.EventCalls["OnRecordBeforeRequestPasswordResetRequest"]++ + return nil + }) + + t.OnRecordAfterRequestPasswordResetRequest().Add(func(e *core.RecordRequestPasswordResetEvent) error { + t.EventCalls["OnRecordAfterRequestPasswordResetRequest"]++ + return nil + }) + + t.OnRecordBeforeConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error { + t.EventCalls["OnRecordBeforeConfirmPasswordResetRequest"]++ + return nil + }) + + t.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error { + t.EventCalls["OnRecordAfterConfirmPasswordResetRequest"]++ + return nil + }) + + t.OnRecordBeforeRequestVerificationRequest().Add(func(e *core.RecordRequestVerificationEvent) error { + t.EventCalls["OnRecordBeforeRequestVerificationRequest"]++ + return nil + }) + + t.OnRecordAfterRequestVerificationRequest().Add(func(e *core.RecordRequestVerificationEvent) error { + t.EventCalls["OnRecordAfterRequestVerificationRequest"]++ + return nil + }) + + t.OnRecordBeforeConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error { + t.EventCalls["OnRecordBeforeConfirmVerificationRequest"]++ + return nil + }) + + t.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error { + t.EventCalls["OnRecordAfterConfirmVerificationRequest"]++ + return nil + }) + + t.OnRecordBeforeRequestEmailChangeRequest().Add(func(e *core.RecordRequestEmailChangeEvent) error { + t.EventCalls["OnRecordBeforeRequestEmailChangeRequest"]++ + return nil + }) + + t.OnRecordAfterRequestEmailChangeRequest().Add(func(e *core.RecordRequestEmailChangeEvent) error { + t.EventCalls["OnRecordAfterRequestEmailChangeRequest"]++ + return nil + }) + + t.OnRecordBeforeConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error { + t.EventCalls["OnRecordBeforeConfirmEmailChangeRequest"]++ + return nil + }) + + t.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error { + t.EventCalls["OnRecordAfterConfirmEmailChangeRequest"]++ + return nil + }) + t.OnRecordListExternalAuthsRequest().Add(func(e *core.RecordListExternalAuthsEvent) error { t.EventCalls["OnRecordListExternalAuthsRequest"]++ return nil