Compare commits

..
72 Commits
Author SHA1 Message Date
Gani Georgiev 0948bf416d [#1822] logged the current datetime on server start 2023-02-12 12:41:48 +02:00
Gani Georgiev 9fd6e7fca1 [#1836] fixed toggle column reactivity on collection change 2023-02-12 12:35:49 +02:00
Gani Georgiev dcaacd0d28 updated ui/dist and slighly increased the colors contrast 2023-02-02 14:09:25 +02:00
Gani Georgiev 21d7cff43d updated changelog 2023-02-02 13:33:05 +02:00
Gani Georgiev 2aec49a1f1 autoclose multi-select dropdown if max select is reached 2023-02-02 13:31:17 +02:00
Gani Georgiev 17b7ee5200 updated the github action's min go version to 1.20.0 2023-02-02 00:21:24 +02:00
Gani Georgiev 2b5f00b62e fix[#1730, #1742] fixed datepicker clear btn and increased theslightly the fields contrast 2023-02-02 00:19:12 +02:00
Gani Georgiev 8b93aac8e3 unregister closed/destroyed overlay panels 2023-01-30 16:31:19 +02:00
Gani Georgiev eb51cdf1aa use the select/deselect helpers on inline RecordUpsert save and delete 2023-01-30 12:10:57 +02:00
Gani Georgiev 647997517f updated changelog 2023-01-30 11:59:14 +02:00
Gani Georgiev 0e1f6b69d0 [#1718] fixed helper overlay-active class not being toggled correctly 2023-01-30 11:56:05 +02:00
Gani Georgiev 6718f4469b updated changelog 2023-01-30 10:18:30 +02:00
Gani Georgiev c27349905c updated js deps 2023-01-30 10:15:02 +02:00
Gani Georgiev 1b48bdb81a fixed changelog typos 2023-01-29 23:23:45 +02:00
Gani Georgiev bb801e7de0 [#1711] added the collection name in the page title 2023-01-29 22:59:11 +02:00
Gani Georgiev adf902cae7 removed unused plugin and legacy license comments from the generated editor theme 2023-01-29 22:29:09 +02:00
Gani Georgiev d2a617848d fixed empty relation(s) save js error 2023-01-29 22:10:14 +02:00
Gani Georgiev 7ae2a7e846 added missing required class to the authentik User API URL 2023-01-29 21:20:41 +02:00
Gani Georgiev 448120bf18 updated ui/dist 2023-01-29 20:13:24 +02:00
Gani Georgiev 667bcac680 updated initial json field reactivity 2023-01-29 20:05:11 +02:00
Gani Georgiev 64fd347628 updated changelog 2023-01-29 18:55:33 +02:00
Gani Georgiev 0340d8add8 updated ui/dist 2023-01-29 18:53:39 +02:00
Hung TranandHarryTr 2c9e8995f9 [#1707] case-insensitive filename extension check
Co-authored-by: HarryTr <hung.tv@hanbiro.com>
2023-01-29 18:52:14 +02:00
Gani Georgiev f01f1df07a updated changelog and json string normalization note 2023-01-29 16:55:46 +02:00
Gani Georgiev f4533f3d26 updated comments and added json string value normalizations info panel 2023-01-29 16:00:03 +02:00
Gani Georgiev 7a47a8a979 updated changelog 2023-01-29 14:16:33 +02:00
Gani Georgiev deccb3dbdb [#1703] updated json field string data normalizations and fixed the field vizualization in the Admin UI 2023-01-29 12:37:10 +02:00
Gani GeorgievandNolan Darilek c51148e4d7 [#1702] added aria-label to some buttons for accessibility
Co-authored-by: Nolan Darilek <nolan@thewordnerd.info>
2023-01-28 20:14:51 +02:00
Gani Georgiev 51ee1b5367 updated tagged hook methods to use h as short var 2023-01-28 20:10:02 +02:00
Gani Georgiev b8d7609e9e added support for optional Model and Record event hook tags 2023-01-27 22:19:08 +02:00
Gani Georgiev 32af49dbec fixed wrong < and ?< operators description 2023-01-27 00:07:37 +02:00
Gani Georgiev cf9e2a33bb updated go deps and added Enclose for grouped conditions for normalization 2023-01-26 22:24:39 +02:00
Gani Georgiev a27298d1ef reset fields id on collection duplicate 2023-01-26 12:12:14 +02:00
Gani Georgiev 536707bfe7 sync with master 2023-01-26 09:23:59 +02:00
Gani Georgiev 2128b15541 updated changelog and ui/dist 2023-01-26 00:11:28 +02:00
Gani Georgiev eb1246fc41 [#1689] fixed cascade delete condition on rel records with the same id as the main record 2023-01-26 00:05:20 +02:00
Gani Georgiev ae371e8481 refactored Record.data and Record.expand to be concurrent safe 2023-01-25 22:39:42 +02:00
Gani Georgiev 39df263a03 [#1656] added duplicate collection and record dropdown option 2023-01-24 22:30:42 +02:00
Gani Georgiev b3fa1f0fea removed n/a values from the mime types list 2023-01-24 22:25:38 +02:00
Gani Georgiev ecfae2e5c9 added predefined mime types list and other minor ui improvements 2023-01-24 20:58:24 +02:00
Gani Georgiev e5477961ad removed unused IdLabel component 2023-01-24 12:45:05 +02:00
Gani Georgiev 2d40487b21 [#1651] added more detailed file upload errors 2023-01-24 12:40:49 +02:00
Gani Georgiev 8564a69a94 updated the default editor toolbar 2023-01-23 22:17:50 +02:00
Gani Georgiev 3f58908734 make the missing displayValue configurable 2023-01-23 22:09:28 +02:00
Gani Georgiev 4c010847e3 [#976] added optional RelationOptions.DisplayFields and refactored the relation picker UI 2023-01-23 21:57:35 +02:00
Gani GeorgievandSteve MacLeod 4c73e16f54 [#1643] added Gitea OAuth2 provider
Co-authored-by: Steve MacLeod <sjmacleoddev@gmail.com>
2023-01-20 10:17:57 +02:00
Gani Georgiev 2a34eca07a synced with master 2023-01-18 17:17:01 +02:00
Gani Georgiev a74d227418 updated ui/dist 2023-01-18 15:49:19 +02:00
Gani Georgiev 7001a22d92 [#1628] fixed realtime panic on concurrent clients iteration 2023-01-18 15:42:04 +02:00
Gani Georgiev a7e3f08df0 fixed typo 2023-01-18 10:05:45 +02:00
Gani Georgiev 15583ba718 updated ui/dist 2023-01-17 23:05:10 +02:00
Gani Georgiev e25c252fc2 [#1623] added apis.RecordAuthResponse helper 2023-01-17 23:04:13 +02:00
Gani Georgiev a15b192a42 added link to the mimetypes supported list 2023-01-17 15:32:16 +02:00
Gani Georgiev 2a4b3315c6 [#370] added rich text editor field 2023-01-17 13:31:48 +02:00
Gani GeorgievandMarc Singer 6d08a5f36f [#1377] added Authentik OAuth2 provider
Co-authored-by: Marc Singer <ms@pr0.tech>
2023-01-16 11:50:45 +02:00
Gani Georgiev fd97732d4d reasign the OAuth2 event fields to make sure that the event always have the latest OAuth2 state 2023-01-15 17:14:52 +02:00
Gani Georgiev 36ab3fd162 [#1240] added dedicated before/after auth hooks and refactored the submit interceptors 2023-01-15 17:00:28 +02:00
Gani Georgiev 8f6f87902a updated README with the supported noncgo platforms 2023-01-14 13:50:19 +02:00
Gani GeorgievandMarios Antonoudiou 55c6bed57f [#1573] added LiveChat OAuth2 provider
Co-authored-by: Marios Antonoudiou <m.antonoudiou@celonis.com>
2023-01-12 22:17:20 +02:00
Gani Georgiev ba7c8e2108 synced with master 2023-01-12 21:18:43 +02:00
Gani Georgiev c1921aeef8 updated changelog 2023-01-12 15:42:46 +02:00
Gani Georgiev 012546e838 removed delete worker pool since it is no longer needed and changed the files delete operation to run in the background (will be replaced with job queue) 2023-01-12 15:34:56 +02:00
Gani Georgiev f792a9e08d fixed ListBucket iterator to always break on seek/forward error 2023-01-12 15:19:27 +02:00
Gani Georgiev 5fb1e85372 fixed formatting 2023-01-12 13:44:37 +02:00
Andrei Varabyeu a5ceee33df Allows files to be read through FileSystem interface.
The functionality is needed while Pocketbase is used in embedded mode
2023-01-12 13:40:40 +02:00
Gani Georgiev 59e4939e1d added unique id validator error 2023-01-11 22:29:48 +02:00
Gani Georgiev 1f46b30895 Merge branch 'master' into develop 2023-01-11 15:56:21 +02:00
Gani Georgiev d37bf6452c updated files preview ui 2023-01-10 22:20:52 +02:00
Gani Georgiev c26ac2d53f upgraded vite 2023-01-10 15:26:36 +02:00
Gani Georgiev e1c751a7e7 synced with master 2023-01-10 15:14:33 +02:00
mjadobson 7459c9208f [#1548] added PDF preview 2023-01-09 21:41:27 +02:00
Gani Georgiev c1ff1c6155 updated go deps 2023-01-09 21:29:20 +02:00
378 changed files with 24403 additions and 3162 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.19.5'
go-version: '>=1.20.0'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
+147
View File
@@ -1,3 +1,150 @@
## v0.12.3
- Fixed "Toggle column" reactivity when navigating between collections ([#1836](https://github.com/pocketbase/pocketbase/pull/1836)).
- Logged the current datetime on server start ([#1822](https://github.com/pocketbase/pocketbase/issues/1822)).
## v0.12.2
- Fixed the "Clear" button of the datepicker component not clearing the value ([#1730](https://github.com/pocketbase/pocketbase/discussions/1730)).
- Increased slightly the fields contrast ([#1742](https://github.com/pocketbase/pocketbase/issues/1742)).
- Auto close the multi-select dropdown if "Max select" is reached.
## v0.12.1
- Fixed js error on empty relation save.
- Fixed `overlay-active` css class not being removed on nested overlay panel close ([#1718](https://github.com/pocketbase/pocketbase/issues/1718)).
- Added the collection name in the page title ([#1711](https://github.com/pocketbase/pocketbase/issues/1711)).
## v0.12.0
- Refactored the relation picker UI to allow server-side search, sort, create, update and delete of relation records ([#976](https://github.com/pocketbase/pocketbase/issues/976)).
- Added new `RelationOptions.DisplayFields` option to specify custom relation field(s) visualization in the Admin UI.
- Added Authentik OAuth2 provider ([#1377](https://github.com/pocketbase/pocketbase/pull/1377); thanks @pr0ton11).
- Added LiveChat OAuth2 provider ([#1573](https://github.com/pocketbase/pocketbase/pull/1573); thanks @mariosant).
- Added Gitea OAuth2 provider ([#1643](https://github.com/pocketbase/pocketbase/pull/1643); thanks @hlanderdev).
- Added PDF file previews ([#1548](https://github.com/pocketbase/pocketbase/pull/1548); thanks @mjadobson).
- Added video and audio file previews.
- Added rich text editor (`editor`) field for HTML content based on TinyMCE ([#370](https://github.com/pocketbase/pocketbase/issues/370)).
_Currently the new field doesn't have any configuration options or validations but this may change in the future depending on how devs ended up using it._
- Added "Duplicate" Collection and Record options in the Admin UI ([#1656](https://github.com/pocketbase/pocketbase/issues/1656)).
- Added `filesystem.GetFile()` helper to read files through the FileSystem abstraction ([#1578](https://github.com/pocketbase/pocketbase/pull/1578); thanks @avarabyeu).
- Added new auth event hooks for finer control and more advanced auth scenarios handling:
```go
// auth record
OnRecordBeforeAuthWithPasswordRequest()
OnRecordAfterAuthWithPasswordRequest()
OnRecordBeforeAuthWithOAuth2Request()
OnRecordAfterAuthWithOAuth2Request()
OnRecordBeforeAuthRefreshRequest()
OnRecordAfterAuthRefreshRequest()
// admin
OnAdminBeforeAuthWithPasswordRequest()
OnAdminAfterAuthWithPasswordRequest()
OnAdminBeforeAuthRefreshRequest()
OnAdminAfterAuthRefreshRequest()
OnAdminBeforeRequestPasswordResetRequest()
OnAdminAfterRequestPasswordResetRequest()
OnAdminBeforeConfirmPasswordResetRequest()
OnAdminAfterConfirmPasswordResetRequest()
```
- Added `models.Record.CleanCopy()` helper that creates a new record copy with only the latest data state of the existing one and all other options reset to their defaults.
- Added new helper `apis.RecordAuthResponse(app, httpContext, record, meta)` to return a standard Record auth API response ([#1623](https://github.com/pocketbase/pocketbase/issues/1623)).
- Refactored `models.Record` expand and data change operations to be concurrent safe.
- Refactored all `forms` Submit interceptors to use a generic data type as their payload.
- Added several `store.Store` helpers:
```go
store.Reset(newData map[string]T)
store.Length() int
store.GetAll() map[string]T
```
- Added "tags" support for all Record and Model related event hooks.
The "tags" allow registering event handlers that will be called only on matching table name(s) or colleciton id(s)/name(s).
For example:
```go
app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
// called only on "articles" record creation
log.Println(e.Record)
return nil
})
```
For all those event hooks `*hook.Hook` was replaced with `*hooks.TaggedHook`, but the hook methods signatures are the same so it should behave as it was previously if no tags were specified.
- **!** Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
In order to support seamlessly both `application/json` and `multipart/form-data`
requests, the following normalization rules are applied if the `json` field is a
**plain string value**:
- "true" is converted to the json `true`
- "false" is converted to the json `false`
- "null" is converted to the json `null`
- "[1,2,3]" is converted to the json `[1,2,3]`
- "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
- numeric strings are converted to json number
- double quoted strings are left as they are (aka. without normalizations)
- any other string (empty string too) is double quoted
Additionally, the "Nonempty" `json` field constraint now checks for `null`, `[]`, `{}` and `""` (empty string).
- Added `aria-label` to some of the buttons in the Admin UI for better accessibility ([#1702](https://github.com/pocketbase/pocketbase/pull/1702); thanks @ndarilek).
- Updated the filename extension checks in the Admin UI to be case-insensitive ([#1707](https://github.com/pocketbase/pocketbase/pull/1707); thanks @hungcrush).
- Other minor improvements (more detailed API file upload errors, UI optimizations, docs improvements, etc.)
## v0.11.4
- Fixed cascade delete for rel records with the same id as the main record ([#1689](https://github.com/pocketbase/pocketbase/issues/1689)).
## v0.11.3
- Fix realtime API panic on concurrent clients iteration ([#1628](https://github.com/pocketbase/pocketbase/issues/1628))
- `app.SubscriptionsBroker().Clients()` now returns a shallow copy of the underlying map.
- Added `Discard()` and `IsDiscarded()` helper methods to the `subscriptions.Client` interface.
- Slow clients should no longer "block" the main action completion.
## v0.11.2
- Fixed `fs.DeleteByPrefix()` hang on invalid S3 settings ([#1575](https://github.com/pocketbase/pocketbase/discussions/1575#discussioncomment-4661089)).
- Updated file(s) delete to run in the background on record/collection delete to avoid blocking the delete model transaction.
_Currently the cascade files delete operation is treated as "non-critical" and in case of an error it is just logged during debug._
_This will be improved in the near future with the planned async job queue implementation._
## v0.11.1
- Unescaped path parameter values ([#1552](https://github.com/pocketbase/pocketbase/issues/1552)).
+25 -3
View File
@@ -90,11 +90,33 @@ Running/building the application is the same as for any other Go program, aka. j
**PocketBase embeds SQLite, but doesn't require CGO.**
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively.
If CGO is enabled (aka. `CGO_ENABLED=1`), it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory.
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo
2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
(_https://go.dev/doc/install/source#environment_)
4. Start the created executable by running `./base serve`.
The supported build targets by the non-cgo driver at the moment are:
```
darwin amd64
darwin arm64
freebsd amd64
freebsd arm64
linux 386
linux amd64
linux arm
linux arm64
linux ppc64le
linux riscv64
windows amd64
windows arm64
```
### Testing
+133 -52
View File
@@ -39,11 +39,10 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.AdminAuthEvent{
HttpContext: c,
Admin: admin,
Token: token,
}
event := new(core.AdminAuthEvent)
event.HttpContext = c
event.Admin = admin
event.Token = token
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
return e.HttpContext.JSON(200, map[string]any{
@@ -59,21 +58,55 @@ func (api *adminApi) authRefresh(c echo.Context) error {
return NewNotFoundError("Missing auth admin context.", nil)
}
return api.authResponse(c, admin)
event := new(core.AdminAuthRefreshEvent)
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
})
if handlerErr == nil {
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *adminApi) authWithPassword(c echo.Context) error {
form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
admin, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
event := new(core.AdminAuthWithPasswordEvent)
event.HttpContext = c
event.Password = form.Password
event.Identity = form.Identity
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
event.Admin = admin
return api.app.OnAdminBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
return api.authResponse(e.HttpContext, e.Admin)
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return api.authResponse(c, admin)
return submitErr
}
func (api *adminApi) requestPasswordReset(c echo.Context) error {
@@ -86,15 +119,40 @@ func (api *adminApi) 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
// (prevents admins enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
event := new(core.AdminRequestPasswordResetEvent)
event.HttpContext = c
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(Admin *models.Admin) error {
event.Admin = Admin
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Admin); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
return c.NoContent(http.StatusNoContent)
if submitErr == nil {
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} 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 *adminApi) confirmPasswordReset(c echo.Context) error {
@@ -103,12 +161,30 @@ func (api *adminApi) 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 := new(core.AdminConfirmPasswordResetEvent)
event.HttpContext = c
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
event.Admin = admin
return api.app.OnAdminBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return c.NoContent(http.StatusNoContent)
return submitErr
}
func (api *adminApi) list(c echo.Context) error {
@@ -126,11 +202,10 @@ func (api *adminApi) list(c echo.Context) error {
return NewBadRequestError("", err)
}
event := &core.AdminsListEvent{
HttpContext: c,
Admins: admins,
Result: result,
}
event := new(core.AdminsListEvent)
event.HttpContext = c
event.Admins = admins
event.Result = result
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -148,10 +223,9 @@ func (api *adminApi) view(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.AdminViewEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminViewEvent)
event.HttpContext = c
event.Admin = admin
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -168,16 +242,17 @@ func (api *adminApi) create(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminCreateEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminCreateEvent)
event.HttpContext = c
event.Admin = admin
// create the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
event.Admin = m
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to create admin.", err)
}
@@ -187,7 +262,9 @@ func (api *adminApi) create(c echo.Context) error {
})
if submitErr == nil {
api.app.OnAdminAfterCreateRequest().Trigger(event)
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -211,16 +288,17 @@ func (api *adminApi) update(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminUpdateEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminUpdateEvent)
event.HttpContext = c
event.Admin = admin
// update the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
event.Admin = m
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to update admin.", err)
}
@@ -230,7 +308,9 @@ func (api *adminApi) update(c echo.Context) error {
})
if submitErr == nil {
api.app.OnAdminAfterUpdateRequest().Trigger(event)
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -247,10 +327,9 @@ func (api *adminApi) delete(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.AdminDeleteEvent{
HttpContext: c,
Admin: admin,
}
event := new(core.AdminDeleteEvent)
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
@@ -261,7 +340,9 @@ func (api *adminApi) delete(c echo.Context) error {
})
if handlerErr == nil {
api.app.OnAdminAfterDeleteRequest().Trigger(event)
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
+26 -10
View File
@@ -14,7 +14,7 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAdminAuthWithEmail(t *testing.T) {
func TestAdminAuthWithPassword(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
@@ -39,6 +39,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "wrong password",
@@ -47,6 +50,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email/password (guest)",
@@ -59,7 +65,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1,
"OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
"OnAdminAuthRequest": 1,
},
},
{
@@ -76,7 +84,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1,
"OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
"OnAdminAuthRequest": 1,
},
},
}
@@ -120,10 +130,12 @@ func TestAdminRequestPasswordReset(t *testing.T) {
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeAdminResetPasswordSend": 1,
"OnMailerAfterAdminResetPasswordSend": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeAdminResetPasswordSend": 1,
"OnMailerAfterAdminResetPasswordSend": 1,
"OnAdminBeforeRequestPasswordResetRequest": 1,
"OnAdminAfterRequestPasswordResetRequest": 1,
},
},
{
@@ -206,8 +218,10 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminBeforeConfirmPasswordResetRequest": 1,
"OnAdminAfterConfirmPasswordResetRequest": 1,
},
},
}
@@ -259,7 +273,9 @@ func TestAdminRefresh(t *testing.T) {
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1,
"OnAdminAuthRequest": 1,
"OnAdminBeforeAuthRefreshRequest": 1,
"OnAdminAfterAuthRefreshRequest": 1,
},
},
}
+3 -4
View File
@@ -71,10 +71,9 @@ func InitApi(app core.App) (*echo.Echo, error) {
apiErr = NewBadRequestError("", err)
}
event := &core.ApiErrorEvent{
HttpContext: c,
Error: apiErr,
}
event := new(core.ApiErrorEvent)
event.HttpContext = c
event.Error = apiErr
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
+47 -40
View File
@@ -1,6 +1,7 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
@@ -42,11 +43,10 @@ func (api *collectionApi) list(c echo.Context) error {
return NewBadRequestError("", err)
}
event := &core.CollectionsListEvent{
HttpContext: c,
Collections: collections,
Result: result,
}
event := new(core.CollectionsListEvent)
event.HttpContext = c
event.Collections = collections
event.Result = result
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -59,10 +59,9 @@ func (api *collectionApi) view(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.CollectionViewEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionViewEvent)
event.HttpContext = c
event.Collection = collection
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -79,16 +78,17 @@ func (api *collectionApi) create(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionCreateEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionCreateEvent)
event.HttpContext = c
event.Collection = collection
// create the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if err := next(); err != nil {
if err := next(e.Collection); err != nil {
return NewBadRequestError("Failed to create the collection.", err)
}
@@ -98,7 +98,9 @@ func (api *collectionApi) create(c echo.Context) error {
})
if submitErr == nil {
api.app.OnCollectionAfterCreateRequest().Trigger(event)
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -117,16 +119,17 @@ func (api *collectionApi) update(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionUpdateEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionUpdateEvent)
event.HttpContext = c
event.Collection = collection
// update the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if err := next(); err != nil {
if err := next(e.Collection); err != nil {
return NewBadRequestError("Failed to update the collection.", err)
}
@@ -136,7 +139,9 @@ func (api *collectionApi) update(c echo.Context) error {
})
if submitErr == nil {
api.app.OnCollectionAfterUpdateRequest().Trigger(event)
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -148,10 +153,9 @@ func (api *collectionApi) delete(c echo.Context) error {
return NewNotFoundError("", err)
}
event := &core.CollectionDeleteEvent{
HttpContext: c,
Collection: collection,
}
event := new(core.CollectionDeleteEvent)
event.HttpContext = c
event.Collection = collection
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
@@ -162,7 +166,9 @@ func (api *collectionApi) delete(c echo.Context) error {
})
if handlerErr == nil {
api.app.OnCollectionAfterDeleteRequest().Trigger(event)
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
@@ -176,18 +182,17 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionsImportEvent{
HttpContext: c,
Collections: form.Collections,
}
event := new(core.CollectionsImportEvent)
event.HttpContext = c
event.Collections = form.Collections
// import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
event.Collections = imports
if err := next(); err != nil {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
if err := next(e.Collections); err != nil {
return NewBadRequestError("Failed to import the submitted collections.", err)
}
@@ -197,7 +202,9 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
})
if submitErr == nil {
api.app.OnCollectionsAfterImportRequest().Trigger(event)
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
+86
View File
@@ -6,10 +6,13 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestCollectionsList(t *testing.T) {
@@ -232,6 +235,7 @@ func TestCollectionDelete(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
@@ -250,6 +254,7 @@ func TestCollectionDelete(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
@@ -744,6 +749,87 @@ func TestCollectionUpdate(t *testing.T) {
`"options":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required"`,
},
},
// rel field change displayFields propagation
{
Name: "renaming a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"id": "w5z2x0nq",
"type": "text",
"name": "title_change"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"title_change"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"title_change", "id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
{
Name: "deleting a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"type": "text",
"name": "new_field"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new_field"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
}
for _, scenario := range scenarios {
+7 -8
View File
@@ -84,14 +84,13 @@ func (api *fileApi) download(c echo.Context) error {
}
}
event := &core.FileDownloadEvent{
HttpContext: c,
Record: record,
Collection: collection,
FileField: fileField,
ServedPath: servedPath,
ServedName: servedName,
}
event := new(core.FileDownloadEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.FileField = fileField
event.ServedPath = servedPath
event.ServedName = servedName
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
res := e.HttpContext.Response()
+27 -13
View File
@@ -15,6 +15,7 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
@@ -43,10 +44,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
client := subscriptions.NewDefaultClient()
api.app.SubscriptionsBroker().Register(client)
defer func() {
api.app.OnRealtimeDisconnectRequest().Trigger(&core.RealtimeDisconnectEvent{
disconnectEvent := &core.RealtimeDisconnectEvent{
HttpContext: c,
Client: client,
})
}
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
log.Println(err)
}
api.app.SubscriptionsBroker().Unregister(client.Id())
}()
@@ -259,21 +264,27 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("create", record)
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return nil
})
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("update", record)
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return nil
})
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("delete", record)
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return nil
})
@@ -337,12 +348,9 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return nil // no subscribers
}
// remove the expand from the broadcasted record because we don't
// know if the clients have access to view the expanded records
cleanRecord := *record
cleanRecord.SetExpand(nil)
cleanRecord.WithUnkownData(false)
cleanRecord.IgnoreEmailVisibility(false)
// create a clean record copy without expand and unknown fields
// because we don't know if the clients have permissions to view them
cleanRecord := record.CleanCopy()
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
@@ -356,7 +364,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
data := &recordData{
Action: action,
Record: &cleanRecord,
Record: cleanRecord,
}
dataBytes, err := json.Marshal(data)
@@ -370,6 +378,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
encodedData := string(dataBytes)
for _, client := range clients {
client := client
for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) {
continue
@@ -398,7 +408,11 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
}
}
client.Channel() <- msg
routine.FireAndForget(func() {
if !client.IsDiscarded() {
client.Channel() <- msg
}
})
}
}
+128 -98
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -14,7 +13,6 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
@@ -51,60 +49,28 @@ type recordAuthApi struct {
app core.App
}
func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record, meta any) error {
token, tokenErr := tokens.NewRecordAuthToken(api.app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.RecordAuthEvent{
HttpContext: c,
Record: authRecord,
Token: token,
Meta: meta,
}
return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
// expand record relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
// create a copy of the cached request data and adjust it to the current auth record
requestData := *RequestData(e.HttpContext)
requestData.Admin = nil
requestData.AuthRecord = e.Record
failed := api.app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(api.app.Dao(), &requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
result := map[string]any{
"token": e.Token,
"record": e.Record,
}
if e.Meta != nil {
result["meta"] = e.Meta
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
func (api *recordAuthApi) authRefresh(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewNotFoundError("Missing auth record context.", nil)
}
return api.authResponse(c, record, nil)
event := new(core.RecordAuthRefreshEvent)
event.HttpContext = c
event.Collection = record.Collection()
event.Record = record
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
if handlerErr == nil {
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
type providerInfo struct {
@@ -202,7 +168,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := RequestData(c)
requestData.Data = form.CreateData
@@ -237,11 +203,39 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
return nil
})
})
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
event := new(core.RecordAuthWithOAuth2Event)
event.HttpContext = c
event.Collection = collection
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
return func(data *forms.RecordOAuth2LoginData) error {
event.Record = data.Record
event.OAuth2User = data.OAuth2User
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
data.Record = e.Record
data.OAuth2User = e.OAuth2User
if err := next(data); err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
e.Record = data.Record
e.OAuth2User = data.OAuth2User
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return api.authResponse(c, record, authData)
return submitErr
}
func (api *recordAuthApi) authWithPassword(c echo.Context) error {
@@ -255,12 +249,33 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
event := new(core.RecordAuthWithPasswordEvent)
event.HttpContext = c
event.Collection = collection
event.Password = form.Password
event.Identity = form.Identity
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return api.authResponse(c, record, nil)
return submitErr
}
func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
@@ -283,11 +298,11 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while validating the form.", err)
}
event := &core.RecordRequestPasswordResetEvent{
HttpContext: c,
}
event := new(core.RecordRequestPasswordResetEvent)
event.HttpContext = c
event.Collection = collection
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -305,7 +320,9 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event)
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
@@ -329,11 +346,11 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmPasswordResetEvent{
HttpContext: c,
}
event := new(core.RecordConfirmPasswordResetEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -348,7 +365,9 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event)
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -369,11 +388,11 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while validating the form.", err)
}
event := &core.RecordRequestVerificationEvent{
HttpContext: c,
}
event := new(core.RecordRequestVerificationEvent)
event.HttpContext = c
event.Collection = collection
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -391,7 +410,9 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterRequestVerificationRequest().Trigger(event)
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
@@ -415,11 +436,11 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmVerificationEvent{
HttpContext: c,
}
event := new(core.RecordConfirmVerificationEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -434,13 +455,20 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event)
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires valid auth record.", nil)
@@ -451,12 +479,12 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.RecordRequestEmailChangeEvent{
HttpContext: c,
Record: record,
}
event := new(core.RecordRequestEmailChangeEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
if err := next(e.Record); err != nil {
@@ -486,11 +514,11 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmEmailChangeEvent{
HttpContext: c,
}
event := new(core.RecordConfirmEmailChangeEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
@@ -505,7 +533,9 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event)
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -532,11 +562,11 @@ func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
}
event := &core.RecordListExternalAuthsEvent{
HttpContext: c,
Record: record,
ExternalAuths: externalAuths,
}
event := new(core.RecordListExternalAuthsEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.ExternalAuths = externalAuths
return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
@@ -565,11 +595,11 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
return NewNotFoundError("Missing external auth provider relation.", err)
}
event := &core.RecordUnlinkExternalAuthEvent{
HttpContext: c,
Record: record,
ExternalAuth: externalAuth,
}
event := new(core.RecordUnlinkExternalAuthEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.ExternalAuth = externalAuth
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
+33 -5
View File
@@ -100,6 +100,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid username and invalid password",
@@ -113,6 +116,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid username and valid password in restricted collection",
@@ -126,6 +132,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid username and valid password in allowed collection",
@@ -143,7 +152,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test2@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
@@ -160,6 +171,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email and invalid password",
@@ -173,6 +187,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email and valid password in restricted collection",
@@ -186,6 +203,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email and valid password in allowed collection",
@@ -203,7 +223,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
@@ -227,7 +249,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
{
@@ -249,7 +273,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
}
@@ -320,7 +346,9 @@ func TestRecordAuthRefresh(t *testing.T) {
`"missing":`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthRefreshRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordAfterAuthRefreshRequest": 1,
},
},
}
+40 -31
View File
@@ -83,12 +83,11 @@ func (api *recordApi) list(c echo.Context) error {
result.Items = records
event := &core.RecordsListEvent{
HttpContext: c,
Collection: collection,
Records: records,
Result: result,
}
event := new(core.RecordsListEvent)
event.HttpContext = c
event.Collection = collection
event.Records = records
event.Result = result
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
@@ -135,10 +134,10 @@ func (api *recordApi) view(c echo.Context) error {
return NewNotFoundError("", fetchErr)
}
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
}
event := new(core.RecordViewEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
@@ -218,16 +217,18 @@ func (api *recordApi) create(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordCreateEvent{
HttpContext: c,
Record: record,
}
event := new(core.RecordCreateEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
// create the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if err := next(); err != nil {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to create record.", err)
}
@@ -241,7 +242,9 @@ func (api *recordApi) create(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterCreateRequest().Trigger(event)
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -302,16 +305,18 @@ func (api *recordApi) update(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordUpdateEvent{
HttpContext: c,
Record: record,
}
event := new(core.RecordUpdateEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
// update the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if err := next(); err != nil {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to update record.", err)
}
@@ -325,7 +330,9 @@ func (api *recordApi) update(c echo.Context) error {
})
if submitErr == nil {
api.app.OnRecordAfterUpdateRequest().Trigger(event)
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
@@ -367,10 +374,10 @@ func (api *recordApi) delete(c echo.Context) error {
return NewNotFoundError("", fetchErr)
}
event := &core.RecordDeleteEvent{
HttpContext: c,
Record: record,
}
event := new(core.RecordDeleteEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
// delete the record
@@ -382,7 +389,9 @@ func (api *recordApi) delete(c echo.Context) error {
})
if handlerErr == nil {
api.app.OnRecordAfterDeleteRequest().Trigger(event)
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
+3
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/models"
@@ -736,6 +737,7 @@ func TestRecordCrudDelete(t *testing.T) {
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 3, // +2 because of the external auths
@@ -822,6 +824,7 @@ func TestRecordCrudDelete(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 2,
+57 -6
View File
@@ -2,13 +2,17 @@ package apis
import (
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
@@ -41,18 +45,65 @@ func RequestData(c echo.Context) *models.RequestData {
return result
}
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := new(core.RecordAuthEvent)
event.HttpContext = c
event.Collection = authRecord.Collection()
event.Record = authRecord
event.Token = token
event.Meta = meta
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
// expand record relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
// create a copy of the cached request data and adjust it to the current auth record
requestData := *RequestData(e.HttpContext)
requestData.Admin = nil
requestData.AuthRecord = e.Record
failed := app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(app.Dao(), &requestData),
)
if len(failed) > 0 && app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
result := map[string]any{
"token": e.Token,
"record": e.Record,
}
if e.Meta != nil {
result["meta"] = e.Meta
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
// EnrichRecord parses the request context and enrich the provided record:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth record and its expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth record and its expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
}
// EnrichRecords parses the request context and enriches the provided records:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth records and their expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth records and their expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
requestData := RequestData(c)
+112
View File
@@ -59,6 +59,118 @@ func TestRequestData(t *testing.T) {
}
}
func TestRecordAuthResponse(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
dummyAdmin := &models.Admin{}
dummyAdmin.Id = "id1"
nonAuthRecord, err := app.Dao().FindRecordById("demo1", "al1h9ijdeojtsjy")
if err != nil {
t.Fatal(err)
}
authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
record *models.Record
meta any
expectError bool
expectedContent []string
notExpectedContent []string
expectedEvents map[string]int
}{
{
name: "non auth record",
record: nonAuthRecord,
expectError: true,
},
{
name: "valid auth record - without meta",
record: authRecord,
expectError: false,
expectedContent: []string{
`"token":"`,
`"record":{`,
`"id":"`,
`"expand":{"rel":{`,
},
notExpectedContent: []string{
`"meta":`,
},
expectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
},
},
{
name: "valid auth record - with meta",
record: authRecord,
meta: map[string]any{"meta_test": 123},
expectError: false,
expectedContent: []string{
`"token":"`,
`"record":{`,
`"id":"`,
`"expand":{"rel":{`,
`"meta":{"meta_test":123`,
},
expectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
},
},
}
for _, s := range scenarios {
app.ResetEventCalls()
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set(apis.ContextAdminKey, dummyAdmin)
responseErr := apis.RecordAuthResponse(app, c, s.record, s.meta)
hasErr := responseErr != nil
if hasErr != s.expectError {
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, responseErr)
}
if len(app.EventCalls) != len(s.expectedEvents) {
t.Fatalf("[%s] Expected events \n%v, \ngot \n%v", s.name, s.expectedEvents, app.EventCalls)
}
for k, v := range s.expectedEvents {
if app.EventCalls[k] != v {
t.Fatalf("[%s] Expected event %s to be called %d times, got %d", s.name, k, v, app.EventCalls[k])
}
}
if hasErr {
continue
}
response := rec.Body.String()
for _, v := range s.expectedContent {
if !strings.Contains(response, v) {
t.Fatalf("[%s] Missing %v in response \n%v", s.name, v, response)
}
}
for _, v := range s.notExpectedContent {
if strings.Contains(response, v) {
t.Fatalf("[%s] Unexpected %v in response \n%v", s.name, v, response)
}
}
}
}
func TestEnrichRecords(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
+16 -13
View File
@@ -2,12 +2,14 @@ package apis
import (
"fmt"
"log"
"net/http"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -32,10 +34,9 @@ func (api *settingsApi) list(c echo.Context) error {
return NewBadRequestError("", err)
}
event := &core.SettingsListEvent{
HttpContext: c,
RedactedSettings: settings,
}
event := new(core.SettingsListEvent)
event.HttpContext = c
event.RedactedSettings = settings
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
@@ -50,17 +51,17 @@ func (api *settingsApi) set(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.SettingsUpdateEvent{
HttpContext: c,
OldSettings: api.app.Settings(),
NewSettings: form.Settings,
}
event := new(core.SettingsUpdateEvent)
event.HttpContext = c
event.OldSettings = api.app.Settings()
// update the settings
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
event.NewSettings = s
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil {
if err := next(e.NewSettings); err != nil {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
@@ -75,7 +76,9 @@ func (api *settingsApi) set(c echo.Context) error {
})
if submitErr == nil {
api.app.OnSettingsAfterUpdateRequest().Trigger(event)
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
+9
View File
@@ -60,6 +60,9 @@ func TestSettingsList(t *testing.T) {
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"authentikAuth":{`,
`"giteaAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -129,6 +132,9 @@ func TestSettingsSet(t *testing.T) {
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"authentikAuth":{`,
`"giteaAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -187,6 +193,9 @@ func TestSettingsSet(t *testing.T) {
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"authentikAuth":{`,
`"giteaAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
+14 -4
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
@@ -90,11 +91,20 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
if httpsAddr != "" {
schema = "https"
}
regular := color.New()
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr))
regular.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, serverConfig.Addr),
)
regular := color.New()
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
}
var serveErr error
+285 -37
View File
@@ -124,27 +124,51 @@ type App interface {
// OnModelBeforeCreate hook is triggered before inserting a new
// entry in the DB, allowing you to modify or validate the stored data.
OnModelBeforeCreate() *hook.Hook[*ModelEvent]
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterCreate hook is triggered after successfully
// inserting a new entry in the DB.
OnModelAfterCreate() *hook.Hook[*ModelEvent]
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeUpdate hook is triggered before updating existing
// entry in the DB, allowing you to modify or validate the stored data.
OnModelBeforeUpdate() *hook.Hook[*ModelEvent]
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterUpdate hook is triggered after successfully updating
// existing entry in the DB.
OnModelAfterUpdate() *hook.Hook[*ModelEvent]
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeDelete hook is triggered before deleting an
// existing entry from the DB.
OnModelBeforeDelete() *hook.Hook[*ModelEvent]
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterDelete is triggered after successfully deleting an
// existing entry from the DB.
OnModelAfterDelete() *hook.Hook[*ModelEvent]
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
// ---------------------------------------------------------------
// Mailer event hooks
@@ -166,33 +190,57 @@ type App interface {
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerAfterRecordResetPasswordSend hook is triggered after
// an auth record password reset email was successfully sent.
OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeRecordVerificationSend hook is triggered right before
// sending a verification email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerAfterRecordVerificationSend hook is triggered after a
// verification email was successfully sent to an auth record.
OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
// sending a confirmation new address email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerAfterRecordChangeEmailSend hook is triggered after a
// verification email was successfully sent to an auth record.
OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// ---------------------------------------------------------------
// Realtime API event hooks
@@ -257,7 +305,7 @@ type App interface {
//
// Could be used to validate or modify the file response before
// returning it to the client.
OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent]
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
// ---------------------------------------------------------------
// Admin API event hooks
@@ -313,6 +361,50 @@ type App interface {
// authenticated admin data and token.
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
// OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin
// auth with password API request (after request data load and before password validation).
//
// Could be used to implement for example a custom password validation
// or to locate a different Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]).
OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
// OnAdminAfterAuthWithPasswordRequest hook is triggered after each
// successful Admin auth with password API request.
OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
// OnAdminBeforeAuthRefreshRequest hook is triggered before each Admin
// auth refresh API request (right before generating a new auth token).
//
// Could be used to additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
// OnAdminAfterAuthRefreshRequest hook is triggered after each
// successful auth refresh API request (right after generating a new auth token).
OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
// OnAdminBeforeRequestPasswordResetRequest hook is triggered before each Admin
// request password reset API request (after request data load and before sending the reset email).
//
// Could be used to additionally validate the request data or implement
// completely different password reset behavior (returning [hook.StopPropagation]).
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
// successful request password reset API request.
OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
// OnAdminBeforeConfirmPasswordResetRequest hook is triggered before each Admin
// confirm password reset API 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]).
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
// successful confirm password reset API request.
OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// ---------------------------------------------------------------
// Record Auth API event hooks
// ---------------------------------------------------------------
@@ -322,89 +414,213 @@ type App interface {
//
// Could be used to additionally validate or modify the authenticated
// record data and token.
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent]
// OnRecordBeforeAuthWithPasswordRequest hook is triggered before each Record
// auth with password API request (after request data load and before password validation).
//
// Could be used to implement for example a custom password validation
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
// OnRecordAfterAuthWithPasswordRequest hook is triggered after each
// successful Record auth with password API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
//
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
// request will try to create a new auth Record.
//
// To assign or link a different existing record model you can
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
// OnRecordAfterAuthWithOAuth2Request hook is triggered after each
// successful Record OAuth2 API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
// OnRecordBeforeAuthRefreshRequest hook is triggered before each Record
// auth refresh API request (right before generating a new auth token).
//
// Could be used to additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
// OnRecordAfterAuthRefreshRequest hook is triggered after each
// successful auth refresh API request (right after generating a new auth token).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
// request password reset API request (after request data load and before sending the reset email).
//
// Could be used to additionally validate the request data or implement
// completely different password reset behavior (returning [hook.StopPropagation]).
OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
// successful request password reset API request.
OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
// OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record
// confirm password reset API 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]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
// successful confirm password reset API request.
OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
// OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
// request verification API request (after request data load and before sending the verification email).
//
// Could be used to additionally validate the loaded request data or implement
// completely different verification behavior (returning [hook.StopPropagation]).
OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
// OnRecordAfterRequestVerificationRequest hook is triggered after each
// successful request verification API request.
OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
// OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record
// confirm verification API 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]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
// successful confirm verification API request.
OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
// OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request
// (after request data load and before sending the email link to confirm the change).
//
// Could be used to additionally validate the request data or implement
// completely different request email change behavior (returning [hook.StopPropagation]).
OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
// successful request email change API request.
OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
// OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record
// confirm email change API 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]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
// successful confirm email change API request.
OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*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.
OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API record external auth unlink request.
OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record CRUD API event hooks
@@ -413,45 +629,77 @@ type App interface {
// OnRecordsListRequest hook is triggered on each API Records list request.
//
// Could be used to validate or modify the response before returning it to the client.
OnRecordsListRequest() *hook.Hook[*RecordsListEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent]
// OnRecordViewRequest hook is triggered on each API Record view request.
//
// Could be used to validate or modify the response before returning it to the client.
OnRecordViewRequest() *hook.Hook[*RecordViewEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent]
// OnRecordBeforeCreateRequest hook is triggered before each API Record
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
// OnRecordAfterCreateRequest hook is triggered after each
// successful API Record create request.
OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
// OnRecordBeforeUpdateRequest hook is triggered before each API Record
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
// OnRecordAfterUpdateRequest hook is triggered after each
// successful API Record update request.
OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
// OnRecordBeforeDeleteRequest hook is triggered before each API Record
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
// OnRecordAfterDeleteRequest hook is triggered after each
// successful API Record delete request.
OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent]
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
// ---------------------------------------------------------------
// Collection API event hooks
+193 -99
View File
@@ -17,6 +17,7 @@ import (
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
@@ -90,18 +91,32 @@ type BaseApp struct {
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
// admin api event hooks
onAdminsListRequest *hook.Hook[*AdminsListEvent]
onAdminViewRequest *hook.Hook[*AdminViewEvent]
onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
onAdminsListRequest *hook.Hook[*AdminsListEvent]
onAdminViewRequest *hook.Hook[*AdminViewEvent]
onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent]
onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent]
onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
onAdminBeforeAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
onAdminAfterAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
onAdminBeforeAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
onAdminAfterAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
onAdminBeforeRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
onAdminAfterRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
onAdminBeforeConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
onAdminAfterConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
// record auth API event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
onRecordBeforeAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
onRecordAfterAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
onRecordBeforeAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
onRecordAfterAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
onRecordBeforeAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
onRecordAfterAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
@@ -211,18 +226,32 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
// admin API event hooks
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
onAdminViewRequest: &hook.Hook[*AdminViewEvent]{},
onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
onAdminViewRequest: &hook.Hook[*AdminViewEvent]{},
onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{},
onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
onAdminBeforeAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
onAdminAfterAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
onAdminBeforeAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
onAdminAfterAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
onAdminBeforeRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
onAdminAfterRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
onAdminBeforeConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
onAdminAfterConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
// record auth API event hooks
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
onRecordBeforeAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
onRecordAfterAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
onRecordBeforeAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
onRecordAfterAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
onRecordBeforeAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
onRecordAfterAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
@@ -512,28 +541,28 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
// Dao event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnModelBeforeCreate() *hook.Hook[*ModelEvent] {
return app.onModelBeforeCreate
func (app *BaseApp) OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelBeforeCreate, tags...)
}
func (app *BaseApp) OnModelAfterCreate() *hook.Hook[*ModelEvent] {
return app.onModelAfterCreate
func (app *BaseApp) OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterCreate, tags...)
}
func (app *BaseApp) OnModelBeforeUpdate() *hook.Hook[*ModelEvent] {
return app.onModelBeforeUpdate
func (app *BaseApp) OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelBeforeUpdate, tags...)
}
func (app *BaseApp) OnModelAfterUpdate() *hook.Hook[*ModelEvent] {
return app.onModelAfterUpdate
func (app *BaseApp) OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterUpdate, tags...)
}
func (app *BaseApp) OnModelBeforeDelete() *hook.Hook[*ModelEvent] {
return app.onModelBeforeDelete
func (app *BaseApp) OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelBeforeDelete, tags...)
}
func (app *BaseApp) OnModelAfterDelete() *hook.Hook[*ModelEvent] {
return app.onModelAfterDelete
func (app *BaseApp) OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterDelete, tags...)
}
// -------------------------------------------------------------------
@@ -548,28 +577,28 @@ func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdmi
return app.onMailerAfterAdminResetPasswordSend
}
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordResetPasswordSend
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerBeforeRecordResetPasswordSend, tags...)
}
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordResetPasswordSend
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerAfterRecordResetPasswordSend, tags...)
}
func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordVerificationSend
func (app *BaseApp) OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerBeforeRecordVerificationSend, tags...)
}
func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordVerificationSend
func (app *BaseApp) OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerAfterRecordVerificationSend, tags...)
}
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordChangeEmailSend
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerBeforeRecordChangeEmailSend, tags...)
}
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordChangeEmailSend
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
return hook.NewTaggedHook(app.onMailerAfterRecordChangeEmailSend, tags...)
}
// -------------------------------------------------------------------
@@ -620,8 +649,8 @@ func (app *BaseApp) OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEve
// File API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] {
return app.onFileDownloadRequest
func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent] {
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}
// -------------------------------------------------------------------
@@ -664,108 +693,164 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
return app.onAdminAuthRequest
}
func (app *BaseApp) OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
return app.onAdminBeforeAuthWithPasswordRequest
}
func (app *BaseApp) OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
return app.onAdminAfterAuthWithPasswordRequest
}
func (app *BaseApp) OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
return app.onAdminBeforeAuthRefreshRequest
}
func (app *BaseApp) OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
return app.onAdminAfterAuthRefreshRequest
}
func (app *BaseApp) OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
return app.onAdminBeforeRequestPasswordResetRequest
}
func (app *BaseApp) OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
return app.onAdminAfterRequestPasswordResetRequest
}
func (app *BaseApp) OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
return app.onAdminBeforeConfirmPasswordResetRequest
}
func (app *BaseApp) OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
return app.onAdminAfterConfirmPasswordResetRequest
}
// -------------------------------------------------------------------
// Record auth API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
return app.onRecordAuthRequest
func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent] {
return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordBeforeRequestPasswordResetRequest
func (app *BaseApp) OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
return hook.NewTaggedHook(app.onRecordBeforeAuthWithPasswordRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordAfterRequestPasswordResetRequest
func (app *BaseApp) OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
return hook.NewTaggedHook(app.onRecordAfterAuthWithPasswordRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordBeforeConfirmPasswordResetRequest
func (app *BaseApp) OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
return hook.NewTaggedHook(app.onRecordBeforeAuthWithOAuth2Request, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordAfterConfirmPasswordResetRequest
func (app *BaseApp) OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
return hook.NewTaggedHook(app.onRecordAfterAuthWithOAuth2Request, tags...)
}
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordBeforeRequestVerificationRequest
func (app *BaseApp) OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
return hook.NewTaggedHook(app.onRecordBeforeAuthRefreshRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordAfterRequestVerificationRequest
func (app *BaseApp) OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
return hook.NewTaggedHook(app.onRecordAfterAuthRefreshRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordBeforeConfirmVerificationRequest
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordBeforeRequestPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordAfterConfirmVerificationRequest
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordAfterRequestPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordBeforeRequestEmailChangeRequest
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordBeforeConfirmPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordAfterRequestEmailChangeRequest
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
return hook.NewTaggedHook(app.onRecordAfterConfirmPasswordResetRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordBeforeConfirmEmailChangeRequest
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
return hook.NewTaggedHook(app.onRecordBeforeRequestVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordAfterConfirmEmailChangeRequest
func (app *BaseApp) OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
return hook.NewTaggedHook(app.onRecordAfterRequestVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
return app.onRecordListExternalAuthsRequest
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
return hook.NewTaggedHook(app.onRecordBeforeConfirmVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
return app.onRecordBeforeUnlinkExternalAuthRequest
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
return hook.NewTaggedHook(app.onRecordAfterConfirmVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
return app.onRecordAfterUnlinkExternalAuthRequest
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordBeforeRequestEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordAfterRequestEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordBeforeConfirmEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
return hook.NewTaggedHook(app.onRecordAfterConfirmEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent] {
return hook.NewTaggedHook(app.onRecordListExternalAuthsRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
return hook.NewTaggedHook(app.onRecordBeforeUnlinkExternalAuthRequest, tags...)
}
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
return hook.NewTaggedHook(app.onRecordAfterUnlinkExternalAuthRequest, tags...)
}
// -------------------------------------------------------------------
// Record CRUD API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] {
return app.onRecordsListRequest
func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent] {
return hook.NewTaggedHook(app.onRecordsListRequest, tags...)
}
func (app *BaseApp) OnRecordViewRequest() *hook.Hook[*RecordViewEvent] {
return app.onRecordViewRequest
func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent] {
return hook.NewTaggedHook(app.onRecordViewRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] {
return app.onRecordBeforeCreateRequest
func (app *BaseApp) OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
return hook.NewTaggedHook(app.onRecordBeforeCreateRequest, tags...)
}
func (app *BaseApp) OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] {
return app.onRecordAfterCreateRequest
func (app *BaseApp) OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
return hook.NewTaggedHook(app.onRecordAfterCreateRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
return app.onRecordBeforeUpdateRequest
func (app *BaseApp) OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
return hook.NewTaggedHook(app.onRecordBeforeUpdateRequest, tags...)
}
func (app *BaseApp) OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
return app.onRecordAfterUpdateRequest
func (app *BaseApp) OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
return hook.NewTaggedHook(app.onRecordAfterUpdateRequest, tags...)
}
func (app *BaseApp) OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
return app.onRecordBeforeDeleteRequest
func (app *BaseApp) OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
return hook.NewTaggedHook(app.onRecordBeforeDeleteRequest, tags...)
}
func (app *BaseApp) OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
return app.onRecordAfterDeleteRequest
func (app *BaseApp) OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
return hook.NewTaggedHook(app.onRecordAfterDeleteRequest, tags...)
}
// -------------------------------------------------------------------
@@ -939,20 +1024,29 @@ func (app *BaseApp) registerDefaultHooks() {
failed := fs.DeletePrefix(prefix)
if len(failed) > 0 {
return errors.New("Failed to delete the files at " + prefix)
return errors.New("failed to delete the files at " + prefix)
}
return nil
}
// delete storage files from deleted Collection, Records, etc.
// try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
prefix := m.BaseFilesPath()
// run in the background for "optimistic" delete to avoid
// blocking the delete transaction
//
// @todo consider creating a bg process queue so that the
// call could be "retried" in case of a failure.
routine.FireAndForget(func() {
if err := deletePrefix(prefix); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
})
}
return nil
-232
View File
@@ -182,238 +182,6 @@ func TestBaseAppGetters(t *testing.T) {
if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil {
t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe)
}
if app.onModelBeforeCreate != app.OnModelBeforeCreate() || app.OnModelBeforeCreate() == nil {
t.Fatalf("Getter app.OnModelBeforeCreate does not match or nil (%v vs %v)", app.OnModelBeforeCreate(), app.onModelBeforeCreate)
}
if app.onModelAfterCreate != app.OnModelAfterCreate() || app.OnModelAfterCreate() == nil {
t.Fatalf("Getter app.OnModelAfterCreate does not match or nil (%v vs %v)", app.OnModelAfterCreate(), app.onModelAfterCreate)
}
if app.onModelBeforeUpdate != app.OnModelBeforeUpdate() || app.OnModelBeforeUpdate() == nil {
t.Fatalf("Getter app.OnModelBeforeUpdate does not match or nil (%v vs %v)", app.OnModelBeforeUpdate(), app.onModelBeforeUpdate)
}
if app.onModelAfterUpdate != app.OnModelAfterUpdate() || app.OnModelAfterUpdate() == nil {
t.Fatalf("Getter app.OnModelAfterUpdate does not match or nil (%v vs %v)", app.OnModelAfterUpdate(), app.onModelAfterUpdate)
}
if app.onModelBeforeDelete != app.OnModelBeforeDelete() || app.OnModelBeforeDelete() == nil {
t.Fatalf("Getter app.OnModelBeforeDelete does not match or nil (%v vs %v)", app.OnModelBeforeDelete(), app.onModelBeforeDelete)
}
if app.onModelAfterDelete != app.OnModelAfterDelete() || app.OnModelAfterDelete() == nil {
t.Fatalf("Getter app.OnModelAfterDelete does not match or nil (%v vs %v)", app.OnModelAfterDelete(), app.onModelAfterDelete)
}
if app.onMailerBeforeAdminResetPasswordSend != app.OnMailerBeforeAdminResetPasswordSend() || app.OnMailerBeforeAdminResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeAdminResetPasswordSend(), app.onMailerBeforeAdminResetPasswordSend)
}
if app.onMailerAfterAdminResetPasswordSend != app.OnMailerAfterAdminResetPasswordSend() || app.OnMailerAfterAdminResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend)
}
if app.onMailerBeforeRecordResetPasswordSend != app.OnMailerBeforeRecordResetPasswordSend() || app.OnMailerBeforeRecordResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordResetPasswordSend(), app.onMailerBeforeRecordResetPasswordSend)
}
if app.onMailerAfterRecordResetPasswordSend != app.OnMailerAfterRecordResetPasswordSend() || app.OnMailerAfterRecordResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordResetPasswordSend(), app.onMailerAfterRecordResetPasswordSend)
}
if app.onMailerBeforeRecordVerificationSend != app.OnMailerBeforeRecordVerificationSend() || app.OnMailerBeforeRecordVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordVerificationSend(), app.onMailerBeforeRecordVerificationSend)
}
if app.onMailerAfterRecordVerificationSend != app.OnMailerAfterRecordVerificationSend() || app.OnMailerAfterRecordVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordVerificationSend(), app.onMailerAfterRecordVerificationSend)
}
if app.onMailerBeforeRecordChangeEmailSend != app.OnMailerBeforeRecordChangeEmailSend() || app.OnMailerBeforeRecordChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordChangeEmailSend(), app.onMailerBeforeRecordChangeEmailSend)
}
if app.onMailerAfterRecordChangeEmailSend != app.OnMailerAfterRecordChangeEmailSend() || app.OnMailerAfterRecordChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordChangeEmailSend(), app.onMailerAfterRecordChangeEmailSend)
}
if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
t.Fatalf("Getter app.OnRealtimeConnectRequest does not match or nil (%v vs %v)", app.OnRealtimeConnectRequest(), app.onRealtimeConnectRequest)
}
if app.onRealtimeBeforeSubscribeRequest != app.OnRealtimeBeforeSubscribeRequest() || app.OnRealtimeBeforeSubscribeRequest() == nil {
t.Fatalf("Getter app.OnRealtimeBeforeSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeBeforeSubscribeRequest(), app.onRealtimeBeforeSubscribeRequest)
}
if app.onRealtimeAfterSubscribeRequest != app.OnRealtimeAfterSubscribeRequest() || app.OnRealtimeAfterSubscribeRequest() == nil {
t.Fatalf("Getter app.OnRealtimeAfterSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeAfterSubscribeRequest(), app.onRealtimeAfterSubscribeRequest)
}
if app.onSettingsListRequest != app.OnSettingsListRequest() || app.OnSettingsListRequest() == nil {
t.Fatalf("Getter app.OnSettingsListRequest does not match or nil (%v vs %v)", app.OnSettingsListRequest(), app.onSettingsListRequest)
}
if app.onSettingsBeforeUpdateRequest != app.OnSettingsBeforeUpdateRequest() || app.OnSettingsBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnSettingsBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsBeforeUpdateRequest(), app.onSettingsBeforeUpdateRequest)
}
if app.onSettingsAfterUpdateRequest != app.OnSettingsAfterUpdateRequest() || app.OnSettingsAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnSettingsAfterUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsAfterUpdateRequest(), app.onSettingsAfterUpdateRequest)
}
if app.onFileDownloadRequest != app.OnFileDownloadRequest() || app.OnFileDownloadRequest() == nil {
t.Fatalf("Getter app.OnFileDownloadRequest does not match or nil (%v vs %v)", app.OnFileDownloadRequest(), app.onFileDownloadRequest)
}
if app.onAdminsListRequest != app.OnAdminsListRequest() || app.OnAdminsListRequest() == nil {
t.Fatalf("Getter app.OnAdminsListRequest does not match or nil (%v vs %v)", app.OnAdminsListRequest(), app.onAdminsListRequest)
}
if app.onAdminViewRequest != app.OnAdminViewRequest() || app.OnAdminViewRequest() == nil {
t.Fatalf("Getter app.OnAdminViewRequest does not match or nil (%v vs %v)", app.OnAdminViewRequest(), app.onAdminViewRequest)
}
if app.onAdminBeforeCreateRequest != app.OnAdminBeforeCreateRequest() || app.OnAdminBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnAdminBeforeCreateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeCreateRequest(), app.onAdminBeforeCreateRequest)
}
if app.onAdminAfterCreateRequest != app.OnAdminAfterCreateRequest() || app.OnAdminAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnAdminAfterCreateRequest does not match or nil (%v vs %v)", app.OnAdminAfterCreateRequest(), app.onAdminAfterCreateRequest)
}
if app.onAdminBeforeUpdateRequest != app.OnAdminBeforeUpdateRequest() || app.OnAdminBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnAdminBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeUpdateRequest(), app.onAdminBeforeUpdateRequest)
}
if app.onAdminAfterUpdateRequest != app.OnAdminAfterUpdateRequest() || app.OnAdminAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnAdminAfterUpdateRequest does not match or nil (%v vs %v)", app.OnAdminAfterUpdateRequest(), app.onAdminAfterUpdateRequest)
}
if app.onAdminBeforeDeleteRequest != app.OnAdminBeforeDeleteRequest() || app.OnAdminBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnAdminBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnAdminBeforeDeleteRequest(), app.onAdminBeforeDeleteRequest)
}
if app.onAdminAfterDeleteRequest != app.OnAdminAfterDeleteRequest() || app.OnAdminAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnAdminAfterDeleteRequest does not match or nil (%v vs %v)", app.OnAdminAfterDeleteRequest(), app.onAdminAfterDeleteRequest)
}
if app.onAdminAuthRequest != app.OnAdminAuthRequest() || app.OnAdminAuthRequest() == nil {
t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest)
}
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
}
if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
}
if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
}
if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
}
if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
}
if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
}
if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
}
if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
}
if app.onRecordAuthRequest != app.OnRecordAuthRequest() || app.OnRecordAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest)
}
if app.onRecordListExternalAuthsRequest != app.OnRecordListExternalAuthsRequest() || app.OnRecordListExternalAuthsRequest() == nil {
t.Fatalf("Getter app.OnRecordListExternalAuthsRequest does not match or nil (%v vs %v)", app.OnRecordListExternalAuthsRequest(), app.onRecordListExternalAuthsRequest)
}
if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUnlinkExternalAuthRequest(), app.onRecordBeforeUnlinkExternalAuthRequest)
}
if app.onRecordAfterUnlinkExternalAuthRequest != app.OnRecordAfterUnlinkExternalAuthRequest() || app.OnRecordAfterUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordAfterUnlinkExternalAuthRequest(), app.onRecordAfterUnlinkExternalAuthRequest)
}
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
}
if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
}
if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
}
if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
}
if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
}
if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
}
if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
}
if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
}
if app.onCollectionsListRequest != app.OnCollectionsListRequest() || app.OnCollectionsListRequest() == nil {
t.Fatalf("Getter app.OnCollectionsListRequest does not match or nil (%v vs %v)", app.OnCollectionsListRequest(), app.onCollectionsListRequest)
}
if app.onCollectionViewRequest != app.OnCollectionViewRequest() || app.OnCollectionViewRequest() == nil {
t.Fatalf("Getter app.OnCollectionViewRequest does not match or nil (%v vs %v)", app.OnCollectionViewRequest(), app.onCollectionViewRequest)
}
if app.onCollectionBeforeCreateRequest != app.OnCollectionBeforeCreateRequest() || app.OnCollectionBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnCollectionBeforeCreateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeCreateRequest(), app.onCollectionBeforeCreateRequest)
}
if app.onCollectionAfterCreateRequest != app.OnCollectionAfterCreateRequest() || app.OnCollectionAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnCollectionAfterCreateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterCreateRequest(), app.onCollectionAfterCreateRequest)
}
if app.onCollectionBeforeUpdateRequest != app.OnCollectionBeforeUpdateRequest() || app.OnCollectionBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnCollectionBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeUpdateRequest(), app.onCollectionBeforeUpdateRequest)
}
if app.onCollectionAfterUpdateRequest != app.OnCollectionAfterUpdateRequest() || app.OnCollectionAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnCollectionAfterUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterUpdateRequest(), app.onCollectionAfterUpdateRequest)
}
if app.onCollectionBeforeDeleteRequest != app.OnCollectionBeforeDeleteRequest() || app.OnCollectionBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnCollectionBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeDeleteRequest(), app.onCollectionBeforeDeleteRequest)
}
if app.onCollectionAfterDeleteRequest != app.OnCollectionAfterDeleteRequest() || app.OnCollectionAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnCollectionAfterDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionAfterDeleteRequest(), app.onCollectionAfterDeleteRequest)
}
}
func TestBaseAppNewMailClient(t *testing.T) {
+1 -1
View File
@@ -11,7 +11,7 @@ func initPragmas(db *dbx.DB) error {
_, err := db.NewQuery(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 100000000;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = TRUE;
`).Execute()
+124 -6
View File
@@ -5,6 +5,8 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
@@ -12,6 +14,28 @@ import (
"github.com/labstack/echo/v5"
)
type BaseCollectionEvent struct {
Collection *models.Collection
}
func (e *BaseCollectionEvent) Tags() []string {
if e.Collection == nil {
return nil
}
tags := make([]string, 0, 2)
if e.Collection.Id != "" {
tags = append(tags, e.Collection.Id)
}
if e.Collection.Name != "" {
tags = append(tags, e.Collection.Name)
}
return tags
}
// -------------------------------------------------------------------
// Serve events data
// -------------------------------------------------------------------
@@ -34,16 +58,32 @@ type ApiErrorEvent struct {
// Model DAO events data
// -------------------------------------------------------------------
var _ hook.Tagger = (*ModelEvent)(nil)
type ModelEvent struct {
Dao *daos.Dao
Model models.Model
}
func (e *ModelEvent) Tags() []string {
if e.Model == nil {
return nil
}
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
return []string{r.Collection().Id, r.Collection().Name}
}
return []string{e.Model.TableName()}
}
// -------------------------------------------------------------------
// Mailer events data
// -------------------------------------------------------------------
type MailerRecordEvent struct {
BaseCollectionEvent
MailClient mailer.Mailer
Message *mailer.Message
Record *models.Record
@@ -103,28 +143,37 @@ type SettingsUpdateEvent struct {
// -------------------------------------------------------------------
type RecordsListEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
Records []*models.Record
Result *search.Result
}
type RecordViewEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordCreateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordUpdateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordDeleteEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
@@ -134,49 +183,91 @@ type RecordDeleteEvent struct {
// -------------------------------------------------------------------
type RecordAuthEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
Token string
Meta any
}
type RecordAuthWithPasswordEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
Identity string
Password string
}
type RecordAuthWithOAuth2Event struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
OAuth2User *auth.AuthUser
}
type RecordAuthRefreshEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordRequestPasswordResetEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmPasswordResetEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordRequestVerificationEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmVerificationEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordRequestEmailChangeEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmEmailChangeEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordListExternalAuthsEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
ExternalAuths []*models.ExternalAuth
}
type RecordUnlinkExternalAuthEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
ExternalAuth *models.ExternalAuth
@@ -218,6 +309,28 @@ type AdminAuthEvent struct {
Token string
}
type AdminAuthWithPasswordEvent struct {
HttpContext echo.Context
Admin *models.Admin
Identity string
Password string
}
type AdminAuthRefreshEvent struct {
HttpContext echo.Context
Admin *models.Admin
}
type AdminRequestPasswordResetEvent struct {
HttpContext echo.Context
Admin *models.Admin
}
type AdminConfirmPasswordResetEvent struct {
HttpContext echo.Context
Admin *models.Admin
}
// -------------------------------------------------------------------
// Collection API events data
// -------------------------------------------------------------------
@@ -229,23 +342,27 @@ type CollectionsListEvent struct {
}
type CollectionViewEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionCreateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionUpdateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionDeleteEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
}
type CollectionsImportEvent struct {
@@ -258,8 +375,9 @@ type CollectionsImportEvent struct {
// -------------------------------------------------------------------
type FileDownloadEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Collection *models.Collection
Record *models.Record
FileField *schema.SchemaField
ServedPath string
+84
View File
@@ -0,0 +1,84 @@
package core_test
import (
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestBaseCollectionEventTags(t *testing.T) {
c1 := new(models.Collection)
c2 := new(models.Collection)
c2.Id = "a"
c3 := new(models.Collection)
c3.Name = "b"
c4 := new(models.Collection)
c4.Id = "a"
c4.Name = "b"
scenarios := []struct {
collection *models.Collection
expectedTags []string
}{
{c1, []string{}},
{c2, []string{"a"}},
{c3, []string{"b"}},
{c4, []string{"a", "b"}},
}
for i, s := range scenarios {
event := new(core.BaseCollectionEvent)
event.Collection = s.collection
tags := event.Tags()
if len(s.expectedTags) != len(tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
for _, tag := range s.expectedTags {
if !list.ExistInSlice(tag, tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
}
}
}
func TestModelEventTags(t *testing.T) {
m1 := new(models.Admin)
c := new(models.Collection)
c.Id = "a"
c.Name = "b"
m2 := models.NewRecord(c)
scenarios := []struct {
model models.Model
expectedTags []string
}{
{m1, []string{"_admins"}},
{m2, []string{"a", "b"}},
}
for i, s := range scenarios {
event := new(core.ModelEvent)
event.Model = s.model
tags := event.Tags()
if len(s.expectedTags) != len(tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
for _, tag := range s.expectedTags {
if !list.ExistInSlice(tag, tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
}
}
}
+20 -44
View File
@@ -3,7 +3,6 @@ package daos
import (
"errors"
"fmt"
"math"
"strings"
"github.com/pocketbase/dbx"
@@ -99,6 +98,7 @@ func (dao *Dao) FindRecordsByIds(
// Returns an empty slice if no records are found.
//
// Example:
//
// expr1 := dbx.HashExp{"email": "test@example.com"}
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
// dao.FindRecordsByExpr("example", expr1, expr2)
@@ -389,8 +389,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
})
}
// cascadeRecordDelete triggers cascade deletion for the provided references
// and split the work to a batched set of go routines.
// cascadeRecordDelete triggers cascade deletion for the provided references.
//
// NB! This method is expected to be called inside a transaction.
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
@@ -400,20 +399,24 @@ func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.
for _, field := range fields {
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
// @todo optimize single relation lookup in v0.12+
query := dao.RecordQuery(refCollection).
Distinct(true).
LeftJoin(fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
InnerJoin(fmt.Sprintf(
// note: the case is used to normalize the value access
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), nil).
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id})).
AndWhere(dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
// trigger cascade for each 1000 rel items until there is none
batchSize := 1000
if refCollection.Id == mainRecord.Collection().Id {
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
}
// trigger cascade for each batchSize rel items until there is none
batchSize := 4000
rows := make([]dbx.NullStringMap, 0, batchSize)
for {
rows := make([]dbx.NullStringMap, 0, batchSize)
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
return err
}
@@ -423,45 +426,18 @@ func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.
break
}
perWorker := 50
workers := int(math.Ceil(float64(total) / float64(perWorker)))
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
batchErr := func() error {
ch := make(chan error)
defer close(ch)
for i := 0; i < workers; i++ {
var chunks []dbx.NullStringMap
if len(rows) <= perWorker {
chunks = rows
rows = nil
} else {
chunks = rows[:perWorker]
rows = rows[perWorker:]
}
go func() {
refRecords := models.NewRecordsFromNullStringMaps(refCollection, chunks)
ch <- dao.deleteRefRecords(mainRecord, refRecords, field)
}()
}
for i := 0; i < workers; i++ {
if err := <-ch; err != nil {
return err
}
}
return nil
}()
if batchErr != nil {
return batchErr
err := dao.deleteRefRecords(mainRecord, refRecords, field)
if err != nil {
return err
}
if total < batchSize {
break // no more items
}
rows = rows[:0] // keep allocated memory
}
}
}
+2
View File
@@ -97,6 +97,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
recordIds[i] = record.Id
}
// @todo after the index optimizations consider allowing
// indirect expand for multi-relation fields
indirectRecords, err := dao.FindRecordsByExpr(
indirectRel.Id,
dbx.In(inflector.Columnify(matches[2]), recordIds...),
+52 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -82,6 +83,8 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
newTableName := newCollection.Name
oldSchema := oldCollection.Schema
newSchema := newCollection.Schema
deletedFieldNames := []string{}
renamedFieldNames := map[string]string{}
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
@@ -101,6 +104,8 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
if err != nil {
return err
}
deletedFieldNames = append(deletedFieldNames, oldField.Name)
}
// check for new or renamed columns
@@ -131,6 +136,8 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
if err != nil {
return err
}
renamedFieldNames[oldField.Name] = field.Name
}
}
@@ -142,6 +149,50 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
}
return nil
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
})
}
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
return nil // nothing to sync
}
refs, err := dao.FindCollectionReferences(collection)
if err != nil {
return err
}
for refCollection, refFields := range refs {
for _, refField := range refFields {
options, _ := refField.Options.(*schema.RelationOptions)
if options == nil {
continue
}
// remove deleted (if any)
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
for old, new := range renamedFieldNames {
for i, name := range newDisplayFields {
if name == old {
newDisplayFields[i] = new
}
}
}
// has changes
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
options.DisplayFields = newDisplayFields
// direct collection save to prevent self-referencing
// recursion and unnecessary records table sync checks
if err := dao.Save(refCollection); err != nil {
return err
}
}
}
}
return nil
}
+30 -3
View File
@@ -663,11 +663,11 @@ func TestDeleteRecord(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
expectedRelManyJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
}
expectedRelOneJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
expectedRelOneJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
}
@@ -693,6 +693,12 @@ func TestDeleteRecordBatchProcessing(t *testing.T) {
t.Fatal("The main record wasn't deleted")
}
// check if the c1 b rel field were updated
c1RecordB, err := app.Dao().FindRecordById("c1", "b")
if err != nil || c1RecordB.GetString("rel") != "" {
t.Fatalf("Expected c1RecordB.rel to be nil, got %v", c1RecordB.GetString("rel"))
}
// check if the c2 rel fields were updated
c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
if err != nil || len(c2Records) == 0 {
@@ -725,6 +731,16 @@ func createMockBatchProcessingData(dao *daos.Dao) error {
Name: "text",
Type: schema.FieldTypeText,
},
// self reference
&schema.SchemaField{
Name: "rel",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(1),
CollectionId: "c1",
CascadeDelete: false, // should unset all rel fields
},
},
)
if err := dao.SaveCollection(c1); err != nil {
return err
@@ -771,15 +787,17 @@ func createMockBatchProcessingData(dao *daos.Dao) error {
// insert mock records
c1RecordA := models.NewRecord(c1)
c1RecordA.Id = "a"
c1RecordA.Set("rel", c1RecordA.Id) // self reference
if err := dao.Save(c1RecordA); err != nil {
return err
}
c1RecordB := models.NewRecord(c1)
c1RecordB.Id = "b"
c1RecordB.Set("rel", c1RecordA.Id) // rel to another record from the same collection
if err := dao.Save(c1RecordB); err != nil {
return err
}
for i := 0; i < 2400; i++ {
for i := 0; i < 4500; i++ {
c2Record := models.NewRecord(c2)
c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
if err := dao.Save(c2Record); err != nil {
@@ -793,5 +811,14 @@ func createMockBatchProcessingData(dao *daos.Dao) error {
}
}
// set the same id as the relation for at least 1 record
// to check whether the correct condition will be added
c3Record := models.NewRecord(c3)
c3Record.Set("rel", c1RecordA.Id)
c3Record.Id = c1RecordA.Id
if err := dao.Save(c3Record); err != nil {
return err
}
return nil
}
+23 -7
View File
@@ -1,6 +1,7 @@
package forms
import (
"database/sql"
"errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -46,19 +47,34 @@ func (form *AdminLogin) Validate() error {
// Submit validates and submits the admin form.
// On success returns the authorized admin model.
func (form *AdminLogin) Submit() (*models.Admin, error) {
//
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *AdminLogin) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
if err := form.Validate(); err != nil {
return nil, err
}
admin, err := form.dao.FindAdminByEmail(form.Identity)
if err != nil {
return nil, err
admin, fetchErr := form.dao.FindAdminByEmail(form.Identity)
// ignore not found errors to allow custom fetch implementations
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
return nil, fetchErr
}
if admin.ValidatePassword(form.Password) {
return admin, nil
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
admin = m
if admin == nil || !admin.ValidatePassword(form.Password) {
return errors.New("Invalid login credentials.")
}
return nil
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return nil, errors.New("Invalid login credentials.")
return admin, nil
}
+47
View File
@@ -1,9 +1,11 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -47,3 +49,48 @@ func TestAdminLoginValidateAndSubmit(t *testing.T) {
}
}
}
func TestAdminLoginInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewAdminLogin(testApp)
form.Identity = "test@example.com"
form.Password = "123456"
var interceptorAdmin *models.Admin
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptor1Called = true
return next(admin)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptorAdmin = admin
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 interceptorAdmin == nil || interceptorAdmin.Email != form.Identity {
t.Fatalf("Expected Admin model with email %s, got %v", form.Identity, interceptorAdmin)
}
}
+11 -3
View File
@@ -63,7 +63,10 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
// 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) {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *AdminPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -80,8 +83,13 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
if err := form.dao.SaveAdmin(admin); err != nil {
return nil, err
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
admin = m
return form.dao.SaveAdmin(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return admin, nil
+71 -1
View File
@@ -1,9 +1,11 @@
package forms_test
import (
"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 TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
form.Password = s.password
form.PasswordConfirm = s.passwordConfirm
admin, err := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptorCalls++
return next(m)
}
}
admin, 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 {
@@ -78,3 +97,54 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
}
}
}
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewAdminPasswordResetConfirm(testApp)
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc"
form.Password = "1234567891"
form.PasswordConfirm = "1234567891"
interceptorTokenKey := admin.TokenKey
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptor1Called = true
return next(admin)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptorTokenKey = admin.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 == admin.TokenKey {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+12 -6
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -55,7 +56,10 @@ func (form *AdminPasswordResetRequest) Validate() error {
// Submit validates and submits the form.
// On success sends a password reset email to the `form.Email` admin.
func (form *AdminPasswordResetRequest) Submit() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -71,12 +75,14 @@ func (form *AdminPasswordResetRequest) Submit() error {
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.dao.SaveAdmin(admin)
return runInterceptors(admin, func(m *models.Admin) error {
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
return err
}
return form.dao.SaveAdmin(m)
}, interceptors...)
}
+69 -1
View File
@@ -1,9 +1,11 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -31,7 +33,24 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email)
err := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptorCalls++
return next(m)
}
}
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 {
@@ -53,3 +72,52 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
}
}
}
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewAdminPasswordResetRequest(testApp)
form.Email = admin.Email
interceptorLastResetSentAt := admin.LastResetSentAt
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptor1Called = true
return next(admin)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptorLastResetSentAt = admin.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() == admin.LastResetSentAt.String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+4 -3
View File
@@ -57,6 +57,7 @@ func (form *AdminUpsert) Validate() error {
form.admin.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.admin.TableName())),
).Else(validation.In(form.admin.Id)),
),
validation.Field(
@@ -98,7 +99,7 @@ func (form *AdminUpsert) checkUniqueEmail(value any) error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -116,7 +117,7 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
form.admin.SetPassword(form.Password)
}
return runInterceptors(func() error {
return form.dao.SaveAdmin(form.admin)
return runInterceptors(form.admin, func(admin *models.Admin) error {
return form.dao.SaveAdmin(admin)
}, interceptors...)
}
+8 -8
View File
@@ -137,10 +137,10 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
interceptorCalls := 0
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
err := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptorCalls++
return next()
return next(m)
}
})
@@ -196,16 +196,16 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
interceptorAdminEmail := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptor1Called = true
return next()
return next(m)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptorAdminEmail = admin.Email // to check if the record was filled
interceptor2Called = true
return testErr
+8 -21
View File
@@ -4,8 +4,6 @@ package forms
import (
"regexp"
"github.com/pocketbase/pocketbase/models"
)
// base ID value regex pattern
@@ -13,32 +11,21 @@ var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
// InterceptorNextFunc is a interceptor handler function.
// Usually used in combination with InterceptorFunc.
type InterceptorNextFunc = func() error
type InterceptorNextFunc[T any] func(t T) error
// InterceptorFunc defines a single interceptor function that
// will execute the provided next func handler.
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
type InterceptorFunc[T any] func(next InterceptorNextFunc[T]) InterceptorNextFunc[T]
// runInterceptors executes the provided list of interceptors.
func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error {
func runInterceptors[T any](
data T,
next InterceptorNextFunc[T],
interceptors ...InterceptorFunc[T],
) error {
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)
return next(data)
}
+41 -10
View File
@@ -9,6 +9,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"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/resolvers"
@@ -93,6 +94,7 @@ func (form *CollectionUpsert) Validate() error {
form.collection.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.collection.TableName())),
).Else(validation.In(form.collection.Id)),
),
validation.Field(
@@ -119,7 +121,7 @@ func (form *CollectionUpsert) Validate() error {
validation.By(form.checkMinSchemaFields),
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureExistingRelationCollectionId),
validation.By(form.checkRelationFields),
validation.When(
isAuth,
validation.By(form.ensureNoAuthFieldName),
@@ -202,9 +204,17 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
return nil
}
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
func (form *CollectionUpsert) checkRelationFields(value any) error {
v, _ := value.(schema.Schema)
systemDisplayFields := schema.BaseModelFieldNames()
systemDisplayFields = append(systemDisplayFields,
schema.FieldNameUsername,
schema.FieldNameEmail,
schema.FieldNameEmailVisibility,
schema.FieldNameVerified,
)
for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation {
continue
@@ -215,11 +225,32 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
continue
}
if err := form.dao.FindById(&models.Collection{}, options.CollectionId); err != nil {
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
)}
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
// validate collectionId
if err != nil || collection.Id != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
),
}},
}
}
// validate displayFields (if any)
for _, name := range options.DisplayFields {
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"displayFields": validation.NewError(
"validation_field_invalid_relation_displayFields",
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
),
}},
}
}
}
}
@@ -343,7 +374,7 @@ func (form *CollectionUpsert) checkOptions(value any) error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Collection]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -375,7 +406,7 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.DeleteRule = form.DeleteRule
form.collection.SetOptions(form.Options)
return runInterceptors(func() error {
return form.dao.SaveCollection(form.collection)
return runInterceptors(form.collection, func(collection *models.Collection) error {
return form.dao.SaveCollection(collection)
}, interceptors...)
}
+36 -9
View File
@@ -168,6 +168,25 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
{
"create failure - missing relation display field",
"",
`{
"name": "test_new",
"type": "base",
"schema": [
{
"name":"test",
"type":"relation",
"options":{
"collectionId":"wsmn24bux7wo113",
"displayFields":["text", "missing"]
}
}
]
}`,
[]string{"schema"},
},
{
"create failure - check type options validators",
"",
@@ -190,7 +209,15 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"system": true,
"schema": [
{"id":"a123456","name":"test1","type":"text"},
{"id":"b123456","name":"test2","type":"email"}
{"id":"b123456","name":"test2","type":"email"},
{
"name":"test3",
"type":"relation",
"options":{
"collectionId":"v851q4r790rhknl",
"displayFields":["name","id","created","updated","username","email","emailVisibility","verified"]
}
}
],
"listRule": "test1='123' && verified = true",
"viewRule": "test1='123' && emailVisibility = true",
@@ -351,10 +378,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCalls++
return next()
return next(c)
}
}
@@ -451,16 +478,16 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
interceptorCollectionName := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptor1Called = true
return next()
return next(c)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCollectionName = collection.Name // to check if the record was filled
interceptor2Called = true
return testErr
+3 -3
View File
@@ -56,15 +56,15 @@ func (form *CollectionsImport) Validate() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.Collection]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(func() error {
return runInterceptors(form.Collections, func(collections []*models.Collection) error {
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
importErr := txDao.ImportCollections(
form.Collections,
collections,
form.DeleteMissing,
form.beforeRecordsSync,
)
+5 -5
View File
@@ -404,16 +404,16 @@ func TestCollectionsImportSubmitInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
interceptor1Called = true
return next()
return next(imports)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
interceptor2Called = true
return testErr
}
+4 -3
View File
@@ -114,9 +114,9 @@ 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`.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -130,7 +130,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithReco
authRecord.SetVerified(true)
authRecord.RefreshTokenKey() // invalidate old tokens
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
return form.dao.SaveRecord(m)
}, interceptors...)
+3 -3
View File
@@ -85,7 +85,7 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -165,7 +165,7 @@ func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -173,7 +173,7 @@ func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorEmail = record.Email()
interceptor2Called = true
+3 -3
View File
@@ -62,14 +62,14 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
// Submit validates and sends the change email request.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptorsWithRecord(form.record, func(m *models.Record) error {
return runInterceptors(form.record, func(m *models.Record) error {
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
}, interceptors...)
}
+3 -3
View File
@@ -60,7 +60,7 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -119,7 +119,7 @@ func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -127,7 +127,7 @@ func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor2Called = true
return testErr
+73 -37
View File
@@ -14,12 +14,25 @@ import (
"golang.org/x/oauth2"
)
// RecordOAuth2LoginData defines the OA
type RecordOAuth2LoginData struct {
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
}
// BeforeOAuth2RecordCreateFunc defines a callback function that will
// be called before OAuth2 new Record creation.
type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error
// RecordOAuth2Login is an auth record OAuth2 login form.
type RecordOAuth2Login struct {
app core.App
dao *daos.Dao
collection *models.Collection
beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc
// Optional auth record that will be used if no external
// auth relation is found (if it is from the same collection)
loggedAuthRecord *models.Record
@@ -62,6 +75,11 @@ func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
form.dao = dao
}
// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
form.beforeOAuth2RecordCreateFunc = f
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordOAuth2Login) Validate() error {
return validation.ValidateStruct(form,
@@ -87,11 +105,14 @@ func (form *RecordOAuth2Login) checkProviderName(value any) error {
//
// If an auth record doesn't exist, it will make an attempt to create it
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
// You can intercept/modify the create form by setting the optional beforeCreateFuncs argument.
// You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
//
// You can also optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
//
// On success returns the authorized record model and the fetched provider's data.
func (form *RecordOAuth2Login) Submit(
beforeCreateFuncs ...func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error,
interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
) (*models.Record, *auth.AuthUser, error) {
if err := form.Validate(); err != nil {
return nil, nil, err
@@ -147,16 +168,40 @@ func (form *RecordOAuth2Login) Submit(
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
}
saveErr := form.dao.RunInTransaction(func(txDao *daos.Dao) error {
if authRecord == nil {
authRecord = models.NewRecord(form.collection)
authRecord.RefreshId()
authRecord.MarkAsNew()
createForm := NewRecordUpsert(form.app, authRecord)
interceptorData := &RecordOAuth2LoginData{
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
}
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
return form.submit(newData)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorData.OAuth2User, interceptorsErr
}
return interceptorData.Record, interceptorData.OAuth2User, nil
}
func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
if data.Record == nil {
data.Record = models.NewRecord(form.collection)
data.Record.RefreshId()
data.Record.MarkAsNew()
createForm := NewRecordUpsert(form.app, data.Record)
createForm.SetFullManageAccess(true)
createForm.SetDao(txDao)
if authUser.Username != "" && usernameRegex.MatchString(authUser.Username) {
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username)
if data.OAuth2User.Username != "" &&
len(data.OAuth2User.Username) >= 3 &&
len(data.OAuth2User.Username) <= 150 &&
usernameRegex.MatchString(data.OAuth2User.Username) {
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
form.collection.Id,
data.OAuth2User.Username,
)
}
// load custom data
@@ -164,10 +209,10 @@ func (form *RecordOAuth2Login) Submit(
// load the OAuth2 profile data as fallback
if createForm.Email == "" {
createForm.Email = authUser.Email
createForm.Email = data.OAuth2User.Email
}
createForm.Verified = false
if createForm.Email == authUser.Email {
if createForm.Email == data.OAuth2User.Email {
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
createForm.Verified = true
}
@@ -176,11 +221,8 @@ func (form *RecordOAuth2Login) Submit(
createForm.PasswordConfirm = createForm.Password
}
for _, f := range beforeCreateFuncs {
if f == nil {
continue
}
if err := f(createForm, authRecord, authUser); err != nil {
if form.beforeOAuth2RecordCreateFunc != nil {
if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
return err
}
}
@@ -190,45 +232,39 @@ func (form *RecordOAuth2Login) Submit(
return err
}
} else {
// update the existing auth record empty email if the authUser has one
// update the existing auth record empty email if the data.OAuth2User has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
if authRecord.Email() == "" && authUser.Email != "" {
authRecord.SetEmail(authUser.Email)
if err := txDao.SaveRecord(authRecord); err != nil {
if data.Record.Email() == "" && data.OAuth2User.Email != "" {
data.Record.SetEmail(data.OAuth2User.Email)
if err := txDao.SaveRecord(data.Record); err != nil {
return err
}
}
// update the existing auth record verified state
// (only if the auth record doesn't have an email or the auth record email match with the one in authUser)
if !authRecord.Verified() && (authRecord.Email() == "" || authRecord.Email() == authUser.Email) {
authRecord.SetVerified(true)
if err := txDao.SaveRecord(authRecord); err != nil {
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
data.Record.SetVerified(true)
if err := txDao.SaveRecord(data.Record); err != nil {
return err
}
}
}
// create ExternalAuth relation if missing
if rel == nil {
rel = &models.ExternalAuth{
CollectionId: authRecord.Collection().Id,
RecordId: authRecord.Id,
if data.ExternalAuth == nil {
data.ExternalAuth = &models.ExternalAuth{
CollectionId: data.Record.Collection().Id,
RecordId: data.Record.Id,
Provider: form.Provider,
ProviderId: authUser.Id,
ProviderId: data.OAuth2User.Id,
}
if err := txDao.SaveExternalAuth(rel); err != nil {
if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
return err
}
}
return nil
})
if saveErr != nil {
return nil, authUser, saveErr
}
return authRecord, authUser, nil
}
+32 -14
View File
@@ -1,6 +1,7 @@
package forms
import (
"database/sql"
"errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -48,30 +49,47 @@ func (form *RecordPasswordLogin) Validate() error {
// Submit validates and submits the form.
// On success returns the authorized record model.
func (form *RecordPasswordLogin) Submit() (*models.Record, error) {
//
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordLogin) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
authOptions := form.collection.AuthOptions()
if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
return nil, errors.New("Password authentication is not allowed for the collection.")
}
var record *models.Record
var authRecord *models.Record
var fetchErr error
if authOptions.AllowEmailAuth &&
(!authOptions.AllowUsernameAuth || is.EmailFormat.Validate(form.Identity) == nil) {
record, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
} else {
record, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
isEmail := is.EmailFormat.Validate(form.Identity) == nil
if isEmail {
if authOptions.AllowEmailAuth {
authRecord, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
}
} else if authOptions.AllowUsernameAuth {
authRecord, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
}
if fetchErr != nil || !record.ValidatePassword(form.Password) {
return nil, errors.New("Invalid login credentials.")
// ignore not found errors to allow custom fetch implementations
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
return nil, fetchErr
}
return record, nil
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
if authRecord == nil || !authRecord.ValidatePassword(form.Password) {
return errors.New("Invalid login credentials.")
}
return nil
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return authRecord, nil
}
+53 -1
View File
@@ -1,13 +1,15 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -128,3 +130,53 @@ func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
}
}
}
func TestRecordPasswordLoginInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordPasswordLogin(testApp, authCollection)
form.Identity = "test@example.com"
form.Password = "123456"
var interceptorRecord *models.Record
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorRecord = record
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 interceptorRecord == nil || interceptorRecord.Email() != form.Identity {
t.Fatalf("Expected auth Record model with email %s, got %v", form.Identity, interceptorRecord)
}
}
+5 -4
View File
@@ -72,9 +72,9 @@ func (form *RecordPasswordResetConfirm) checkToken(value any) error {
// Submit validates and submits the form.
// On success returns the updated auth record associated to `form.Token`.
//
// 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) {
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -91,7 +91,8 @@ func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRe
return nil, err
}
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
return form.dao.SaveRecord(m)
}, interceptors...)
+3 -3
View File
@@ -79,7 +79,7 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -157,7 +157,7 @@ func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -165,7 +165,7 @@ func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorTokenKey = record.TokenKey()
interceptor2Called = true
+4 -4
View File
@@ -60,9 +60,9 @@ func (form *RecordPasswordResetRequest) Validate() error {
// Submit validates and submits the form.
// On success, sends a password reset email to the `form.Email` auth record.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -81,7 +81,7 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRe
// update last sent timestamp
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
return runInterceptors(authRecord, func(m *models.Record) error {
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
return err
}
+3 -3
View File
@@ -67,7 +67,7 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -135,7 +135,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -143,7 +143,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorLastResetSentAt = record.LastResetSentAt()
interceptor2Called = true
+6 -3
View File
@@ -455,6 +455,7 @@ func (form *RecordUpsert) Validate() error {
form.record.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.record.TableName())),
).Else(validation.In(form.record.Id)),
),
}
@@ -466,7 +467,7 @@ func (form *RecordUpsert) Validate() error {
&form.Username,
// require only on update, because on create we fallback to auto generated username
validation.When(!form.record.IsNew(), validation.Required),
validation.Length(3, 100),
validation.Length(3, 150),
validation.Match(usernameRegex),
validation.By(form.checkUniqueUsername),
),
@@ -717,12 +718,14 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.ValidateAndFill(); err != nil {
return err
}
return runInterceptors(func() error {
return runInterceptors(form.record, func(record *models.Record) error {
form.record = record
if !form.record.HasId() {
form.record.RefreshId()
form.record.MarkAsNew()
+13 -13
View File
@@ -428,10 +428,10 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
form.LoadRequest(req, "")
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next()
return next(r)
}
}
@@ -505,10 +505,10 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
form.LoadRequest(req, "")
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next()
return next(r)
}
}
@@ -566,16 +566,16 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
interceptorRecordTitle := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptor1Called = true
return next()
return next(r)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorRecordTitle = record.GetString("title") // to check if the record was filled
interceptor2Called = true
return testErr
@@ -780,10 +780,10 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
true,
},
{
"invalid username length (more than 100)",
"invalid username length (more than 150)",
"",
map[string]any{
"username": strings.Repeat("a", 101),
"username": strings.Repeat("a", 151),
"password": "12345678",
"passwordConfirm": "12345678",
},
+6 -4
View File
@@ -77,9 +77,9 @@ func (form *RecordVerificationConfirm) checkToken(value any) error {
// Submit validates and submits the form.
// On success returns the verified auth record associated to `form.Token`.
//
// 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) {
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -98,7 +98,9 @@ func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRec
record.SetVerified(true)
}
interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error {
interceptorsErr := runInterceptors(record, func(m *models.Record) error {
record = m
if wasVerified {
return nil // already verified
}
+3 -3
View File
@@ -57,7 +57,7 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -117,7 +117,7 @@ func TestRecordVerificationConfirmInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -125,7 +125,7 @@ func TestRecordVerificationConfirmInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorVerified = record.Verified()
interceptor2Called = true
+4 -4
View File
@@ -60,9 +60,9 @@ func (form *RecordVerificationRequest) Validate() error {
// Submit validates and sends a verification request email
// to the `form.Email` auth record.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.Validate(); err != nil {
return err
}
@@ -87,7 +87,7 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRec
record.SetLastVerificationSentAt(types.NowDateTime())
}
return runInterceptorsWithRecord(record, func(m *models.Record) error {
return runInterceptors(record, func(m *models.Record) error {
if m.Verified() {
return nil // already verified
}
+3 -3
View File
@@ -85,7 +85,7 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
@@ -153,7 +153,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
@@ -161,7 +161,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorLastVerificationSentAt = record.LastVerificationSentAt()
interceptor2Called = true
+4 -2
View File
@@ -50,12 +50,14 @@ func (form *SettingsUpsert) Validate() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Settings]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(func() error {
return runInterceptors(form.Settings, func(s *settings.Settings) error {
form.Settings = s
encryptionKey := os.Getenv(form.app.EncryptionEnv())
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
return err
+9 -8
View File
@@ -8,6 +8,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -78,10 +79,10 @@ func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
interceptorCalls++
return next()
return next(s)
}
}
@@ -135,16 +136,16 @@ func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
interceptor1Called = true
return next()
return next(s)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
interceptor2Called = true
return testErr
}
+20 -8
View File
@@ -22,7 +22,10 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
}
if int(v.Size) > maxBytes {
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
return validation.NewError(
"validation_file_size_limit",
fmt.Sprintf("Failed to upload %q - the maximum allowed file size is %v bytes.", v.OriginalName, maxBytes),
)
}
return nil
@@ -42,19 +45,24 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
return nil // nothing to validate
}
baseErr := validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
)
if len(validTypes) == 0 {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
return baseErr
}
f, err := v.Reader.Open()
if err != nil {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
return baseErr
}
defer f.Close()
filetype, err := mimetype.DetectReader(f)
if err != nil {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
return baseErr
}
for _, t := range validTypes {
@@ -63,9 +71,13 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
}
}
return validation.NewError("validation_invalid_mime_type", fmt.Sprintf(
"The following mime types are only allowed: %s.",
strings.Join(validTypes, ","),
))
return validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf(
"%q mime type must be one of: %s.",
v.Name,
strings.Join(validTypes, ", "),
),
)
}
}
+38
View File
@@ -0,0 +1,38 @@
package validators
import (
"database/sql"
"errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
)
// Compare checks whether the provided model id exists.
//
// Example:
// validation.Field(&form.Id, validation.By(validators.UniqueId(form.dao, tableName)))
func UniqueId(dao *daos.Dao, tableName string) validation.RuleFunc {
return func(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
var foundId string
err := dao.DB().
Select("id").
From(tableName).
Where(dbx.HashExp{"id": v}).
Limit(1).
Row(&foundId)
if (err != nil && !errors.Is(err, sql.ErrNoRows)) || foundId != "" {
return validation.NewError("validation_invalid_id", "The model id is invalid or already exists.")
}
return nil
}
}
+34
View File
@@ -0,0 +1,34 @@
package validators_test
import (
"testing"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/tests"
)
func TestUniqueId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
id string
tableName string
expectError bool
}{
{"", "", false},
{"test", "", true},
{"wsmn24bux7wo113", "_collections", true},
{"test_unique_id", "unknown_table", true},
{"test_unique_id", "_collections", false},
}
for i, s := range scenarios {
err := validators.UniqueId(app.Dao(), s.tableName)(s.id)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
+18 -5
View File
@@ -23,6 +23,7 @@ var requiredErr = validation.NewError("validation_required", "Missing required v
// using the provided record constraints and schema.
//
// Example:
//
// validator := NewRecordDataValidator(app.Dao(), record, nil)
// err := validator.Validate(map[string]any{"test":123})
func NewRecordDataValidator(
@@ -117,6 +118,8 @@ func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField,
return validator.checkEmailValue(field, value)
case schema.FieldTypeUrl:
return validator.checkUrlValue(field, value)
case schema.FieldTypeEditor:
return validator.checkEditorValue(field, value)
case schema.FieldTypeDate:
return validator.checkDateValue(field, value)
case schema.FieldTypeSelect:
@@ -236,6 +239,10 @@ func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, v
return nil
}
func (validator *RecordDataValidator) checkEditorValue(field *schema.SchemaField, value any) error {
return nil
}
func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
val, _ := value.(types.DateTime)
if val.IsZero() {
@@ -288,16 +295,22 @@ func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField
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
}
var emptyJsonValues = []string{
"null", `""`, "[]", "{}",
}
func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error {
if is.JSON.Validate(value) != nil {
return validation.NewError("validation_invalid_json", "Must be a valid json value")
}
raw, _ := types.ParseJsonRaw(value)
rawStr := strings.TrimSpace(raw.String())
if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
return requiredErr
}
return nil
}
+17 -7
View File
@@ -965,7 +965,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
"field3": []string{},
},
nil,
[]string{},
[]string{"field2"},
},
{
"(json) check required constraint - zero map",
@@ -975,7 +975,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
"field3": map[string]string{},
},
nil,
[]string{},
[]string{"field2"},
},
{
"(json) check unique constraint",
@@ -988,14 +988,24 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{"field3"},
},
{
"(json) check json text validator",
"(json) check json text invalid obj, array and number normalizations",
map[string]any{
"field1": `[1, 2, 3`,
"field2": `invalid`,
"field3": `null`, // valid
"field1": `[1 2 3]`,
"field2": `{a: 123}`,
"field3": `123.456 abc`,
},
nil,
[]string{"field1", "field2"},
[]string{},
},
{
"(json) check json text reserved literals normalizations",
map[string]any{
"field1": `true`,
"field2": `false`,
"field3": `null`,
},
nil,
[]string{},
},
{
"(json) valid data - only required fields",
+15 -15
View File
@@ -4,37 +4,37 @@ go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/aws/aws-sdk-go v1.44.175
github.com/aws/aws-sdk-go v1.44.187
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.3.4
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
github.com/fatih/color v1.13.0
github.com/fatih/color v1.14.1
github.com/gabriel-vasile/mimetype v1.4.1
github.com/ganigeorgiev/fexpr v0.3.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v4 v4.4.3
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
github.com/mattn/go-sqlite3 v1.14.16
github.com/pocketbase/dbx v1.8.0
github.com/pocketbase/dbx v1.9.0
github.com/spf13/cast v1.5.0
github.com/spf13/cobra v1.6.1
gocloud.dev v0.27.0
gocloud.dev v0.28.0
golang.org/x/crypto v0.5.0
golang.org/x/net v0.5.0
golang.org/x/oauth2 v0.4.0
golang.org/x/sync v0.1.0
modernc.org/sqlite v1.20.2
modernc.org/sqlite v1.20.3
)
require (
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/aws/aws-sdk-go-v2 v1.17.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.47 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.49 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 // indirect
@@ -43,13 +43,13 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.2 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
@@ -62,7 +62,7 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
@@ -75,10 +75,10 @@ require (
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.5.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.106.0 // indirect
google.golang.org/api v0.108.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230106154932-a12b697841d9 // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa // indirect
google.golang.org/grpc v1.52.3 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
+571 -138
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -61,12 +61,11 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
HTML: body,
}
event := &core.MailerAdminEvent{
MailClient: mailClient,
Message: message,
Admin: admin,
Meta: map[string]any{"token": token},
}
event := new(core.MailerAdminEvent)
event.MailClient = mailClient
event.Message = message
event.Admin = admin
event.Meta = map[string]any{"token": token}
sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
return e.MailClient.Send(e.Message)
+20 -20
View File
@@ -37,12 +37,12 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
HTML: body,
}
event := &core.MailerRecordEvent{
MailClient: mailClient,
Message: message,
Record: authRecord,
Meta: map[string]any{"token": token},
}
event := new(core.MailerRecordEvent)
event.MailClient = mailClient
event.Message = message
event.Collection = authRecord.Collection()
event.Record = authRecord
event.Meta = map[string]any{"token": token}
sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
return e.MailClient.Send(e.Message)
@@ -81,12 +81,12 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
HTML: body,
}
event := &core.MailerRecordEvent{
MailClient: mailClient,
Message: message,
Record: authRecord,
Meta: map[string]any{"token": token},
}
event := new(core.MailerRecordEvent)
event.MailClient = mailClient
event.Message = message
event.Collection = authRecord.Collection()
event.Record = authRecord
event.Meta = map[string]any{"token": token}
sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
return e.MailClient.Send(e.Message)
@@ -125,14 +125,14 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
HTML: body,
}
event := &core.MailerRecordEvent{
MailClient: mailClient,
Message: message,
Record: record,
Meta: map[string]any{
"token": token,
"newEmail": newEmail,
},
event := new(core.MailerRecordEvent)
event.MailClient = mailClient
event.Message = message
event.Collection = record.Collection()
event.Record = record
event.Meta = map[string]any{
"token": token,
"newEmail": newEmail,
}
sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
-1
View File
@@ -128,7 +128,6 @@ func init() {
MaxSelect: 1,
MaxSize: 5242880,
MimeTypes: []string{
"image/jpg",
"image/jpeg",
"image/png",
"image/svg+xml",
+83 -38
View File
@@ -12,6 +12,7 @@ import (
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/crypto/bcrypt"
@@ -28,19 +29,19 @@ type Record struct {
collection *Collection
exportUnknown bool // whether to export unknown fields
ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
data map[string]any // any custom data in addition to the base model fields
expand map[string]any // expanded relations
exportUnknown bool // whether to export unknown fields
ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
loaded bool
originalData map[string]any // the original (aka. first loaded) model data
originalData map[string]any // the original (aka. first loaded) model data
expand *store.Store[any] // expanded relations
data *store.Store[any] // any custom data in addition to the base model fields
}
// NewRecord initializes a new empty Record model.
func NewRecord(collection *Collection) *Record {
return &Record{
collection: collection,
data: map[string]any{},
data: store.New[any](nil),
}
}
@@ -109,7 +110,8 @@ func (m *Record) Collection() *Collection {
}
// OriginalCopy returns a copy of the current record model populated
// with its original (aka. the initially loaded) data state.
// with its ORIGINAL data state (aka. the initially loaded) and
// everything else reset to the defaults.
func (m *Record) OriginalCopy() *Record {
newRecord := NewRecord(m.collection)
newRecord.Load(m.originalData)
@@ -123,15 +125,40 @@ func (m *Record) OriginalCopy() *Record {
return newRecord
}
// Expand returns a shallow copy of the record.expand data
// attached to the current Record model.
func (m *Record) Expand() map[string]any {
return shallowCopy(m.expand)
// CleanCopy returns a copy of the current record model populated only
// with its LATEST data state and everything else reset to the defaults.
func (m *Record) CleanCopy() *Record {
newRecord := NewRecord(m.collection)
newRecord.Load(m.data.GetAll())
newRecord.Id = m.Id
newRecord.Created = m.Created
newRecord.Updated = m.Updated
if m.IsNew() {
newRecord.MarkAsNew()
} else {
newRecord.MarkAsNotNew()
}
return newRecord
}
// SetExpand assigns the provided data to record.expand.
// Expand returns a shallow copy of the current Record model expand data.
func (m *Record) Expand() map[string]any {
if m.expand == nil {
m.expand = store.New[any](nil)
}
return m.expand.GetAll()
}
// SetExpand shallow copies the provided data to the current Record model's expand.
func (m *Record) SetExpand(expand map[string]any) {
m.expand = shallowCopy(expand)
if m.expand == nil {
m.expand = store.New[any](nil)
}
m.expand.Reset(expand)
}
// MergeExpand merges recursively the provided expand data into
@@ -141,14 +168,23 @@ func (m *Record) SetExpand(expand map[string]any) {
// then both old and new records will be merged into a new slice (aka. a :merge: [b,c] => [a,b,c]).
// Otherwise the "old" expanded record will be replace with the "new" one (aka. a :merge: aNew => aNew).
func (m *Record) MergeExpand(expand map[string]any) {
if m.expand == nil && len(expand) > 0 {
m.expand = make(map[string]any, len(expand))
// nothing to merge
if len(expand) == 0 {
return
}
// no old expand
if m.expand == nil {
m.expand = store.New(expand)
return
}
oldExpand := m.expand.GetAll()
for key, new := range expand {
old, ok := m.expand[key]
old, ok := oldExpand[key]
if !ok {
m.expand[key] = new
oldExpand[key] = new
continue
}
@@ -163,7 +199,7 @@ func (m *Record) MergeExpand(expand map[string]any) {
default:
// invalid old expand data -> assign directly the new
// (no matter whether new is valid or not)
m.expand[key] = new
oldExpand[key] = new
continue
}
@@ -197,19 +233,23 @@ func (m *Record) MergeExpand(expand map[string]any) {
}
if wasOldSlice || wasNewSlice || len(oldSlice) == 0 {
m.expand[key] = oldSlice
oldExpand[key] = oldSlice
} else {
m.expand[key] = oldSlice[0]
oldExpand[key] = oldSlice[0]
}
}
m.expand.Reset(oldExpand)
}
// SchemaData returns a shallow copy ONLY of the defined record schema fields data.
func (m *Record) SchemaData() map[string]any {
result := make(map[string]any, len(m.collection.Schema.Fields()))
data := m.data.GetAll()
for _, field := range m.collection.Schema.Fields() {
if v, ok := m.data[field.Name]; ok {
if v, ok := data[field.Name]; ok {
result[field.Name] = v
}
}
@@ -221,7 +261,11 @@ func (m *Record) SchemaData() map[string]any {
// aka. fields that are neither one of the base and special system ones,
// nor defined by the collection schema.
func (m *Record) UnknownData() map[string]any {
return m.extractUnknownData(m.data)
if m.data == nil {
return nil
}
return m.extractUnknownData(m.data.GetAll())
}
// IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check.
@@ -267,10 +311,10 @@ func (m *Record) Set(key string, value any) {
}
if m.data == nil {
m.data = map[string]any{}
m.data = store.New[any](nil)
}
m.data[key] = v
m.data.Set(key, v)
}
}
@@ -284,11 +328,11 @@ func (m *Record) Get(key string) any {
case schema.FieldNameUpdated:
return m.Updated
default:
if v, ok := m.data[key]; ok {
return v
if m.data == nil {
return nil
}
return nil
return m.data.Get(key)
}
}
@@ -331,10 +375,11 @@ func (m *Record) GetStringSlice(key string) []string {
// Retrieves the "key" json field value and unmarshals it into "result".
//
// Example
// result := struct {
// FirstName string `json:"first_name"`
// }{}
// err := m.UnmarshalJSONField("my_field_name", &result)
//
// result := struct {
// FirstName string `json:"first_name"`
// }{}
// err := m.UnmarshalJSONField("my_field_name", &result)
func (m *Record) UnmarshalJSONField(key string, result any) error {
return json.Unmarshal([]byte(m.GetString(key)), &result)
}
@@ -432,8 +477,8 @@ func (m *Record) PublicExport() map[string]any {
result[schema.FieldNameCollectionName] = m.collection.Name
// add expand (if set)
if m.expand != nil {
result[schema.FieldNameExpand] = m.expand
if m.expand != nil && m.expand.Length() > 0 {
result[schema.FieldNameExpand] = m.expand.GetAll()
}
return result
@@ -469,10 +514,10 @@ func (m *Record) UnmarshalJSON(data []byte) error {
//
// Example usage:
//
// newData := record.ReplaceModifers(data)
// // record: {"field": 10}
// // data: {"field+": 5}
// // newData: {"field": 15}
// newData := record.ReplaceModifers(data)
// // record: {"field": 10}
// // data: {"field+": 5}
// // newData: {"field": 15}
func (m *Record) ReplaceModifers(data map[string]any) map[string]any {
var clone = shallowCopy(data)
if len(clone) == 0 {
@@ -624,7 +669,7 @@ func (m *Record) extractUnknownData(data map[string]any) map[string]any {
result := map[string]any{}
for k, v := range m.data {
for k, v := range data {
if _, ok := knownFields[k]; !ok {
result[k] = v
}
+35 -2
View File
@@ -1,6 +1,7 @@
package models_test
import (
"bytes"
"database/sql"
"encoding/json"
"testing"
@@ -346,7 +347,7 @@ func TestRecordOriginalCopy(t *testing.T) {
t.Fatalf("Expected the initial/original f to be %q, got %q", "123", v)
}
// Loading new data shouldn't affect the original state
// loading new data shouldn't affect the original state
m.Load(map[string]any{"f": "789"})
if v := m.GetString("f"); v != "789" {
@@ -358,6 +359,38 @@ func TestRecordOriginalCopy(t *testing.T) {
}
}
func TestRecordCleanCopy(t *testing.T) {
m := models.NewRecord(&models.Collection{
Name: "cname",
Type: models.CollectionTypeAuth,
})
m.Load(map[string]any{
"id": "id1",
"created": "2023-01-01 00:00:00.000Z",
"updated": "2023-01-02 00:00:00.000Z",
"username": "test",
"verified": true,
"email": "test@example.com",
"unknown": "456",
})
// make a change to ensure that the latest data is targeted
m.Set("id", "id2")
// allow the special flags and options to check whether they will be ignored
m.SetExpand(map[string]any{"test": 123})
m.IgnoreEmailVisibility(true)
m.WithUnkownData(true)
copy := m.CleanCopy()
copyExport, _ := copy.MarshalJSON()
expectedExport := []byte(`{"collectionId":"","collectionName":"cname","created":"2023-01-01 00:00:00.000Z","emailVisibility":false,"id":"id2","updated":"2023-01-02 00:00:00.000Z","username":"test","verified":true}`)
if !bytes.Equal(copyExport, expectedExport) {
t.Fatalf("Expected clean export \n%s, \ngot \n%s", expectedExport, copyExport)
}
}
func TestRecordSetAndGetExpand(t *testing.T) {
collection := &models.Collection{}
m := models.NewRecord(collection)
@@ -928,7 +961,7 @@ func TestRecordUnmarshalJSONField(t *testing.T) {
expectedJson string
}{
{nil, testStr, true, `""`},
{"", testStr, true, `""`},
{"", testStr, false, `""`},
{1, testInt, false, `1`},
{true, testBool, false, `true`},
{[]int{1, 2, 3}, testSlice, false, `[1,2,3]`},
+60 -6
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"regexp"
"strconv"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
@@ -86,6 +87,7 @@ const (
FieldTypeBool string = "bool"
FieldTypeEmail string = "email"
FieldTypeUrl string = "url"
FieldTypeEditor string = "editor"
FieldTypeDate string = "date"
FieldTypeSelect string = "select"
FieldTypeJson string = "json"
@@ -104,6 +106,7 @@ func FieldTypes() []string {
FieldTypeBool,
FieldTypeEmail,
FieldTypeUrl,
FieldTypeEditor,
FieldTypeDate,
FieldTypeSelect,
FieldTypeJson,
@@ -183,7 +186,7 @@ func (f SchemaField) Validate() error {
excludeNames := BaseModelFieldNames()
// exclude special filter literals
excludeNames = append(excludeNames, "null", "true", "false", "isset")
excludeNames = append(excludeNames, "null", "true", "false")
// exclude system literals
excludeNames = append(excludeNames, SystemFieldNames()...)
@@ -238,6 +241,8 @@ func (f *SchemaField) InitOptions() error {
options = &EmailOptions{}
case FieldTypeUrl:
options = &UrlOptions{}
case FieldTypeEditor:
options = &EditorOptions{}
case FieldTypeDate:
options = &DateOptions{}
case FieldTypeSelect:
@@ -272,10 +277,38 @@ func (f *SchemaField) PrepareValue(value any) any {
f.InitOptions()
switch f.Type {
case FieldTypeText, FieldTypeEmail, FieldTypeUrl:
case FieldTypeText, FieldTypeEmail, FieldTypeUrl, FieldTypeEditor:
return cast.ToString(value)
case FieldTypeJson:
val, _ := types.ParseJsonRaw(value)
val := value
if str, ok := val.(string); ok {
// in order to support seamlessly both json and multipart/form-data requests,
// the following normalization rules are applied for plain string values:
// - "true" is converted to the json `true`
// - "false" is converted to the json `false`
// - "null" is converted to the json `null`
// - "[1,2,3]" is converted to the json `[1,2,3]`
// - "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
// - numeric strings are converted to json number
// - double quoted strings are left as they are (aka. without normalizations)
// - any other string (empty string too) is double quoted
if str == "" {
val = strconv.Quote(str)
} else if str == "null" || str == "true" || str == "false" {
val = str
} else if ((str[0] >= '0' && str[0] <= '9') ||
str[0] == '"' ||
str[0] == '[' ||
str[0] == '{') &&
is.JSON.Validate(str) == nil {
val = str
} else {
val = strconv.Quote(str)
}
}
val, _ = types.ParseJsonRaw(val)
return val
case FieldTypeNumber:
return cast.ToFloat64(value)
@@ -473,6 +506,15 @@ func (o UrlOptions) Validate() error {
// -------------------------------------------------------------------
type EditorOptions struct {
}
func (o EditorOptions) Validate() error {
return nil
}
// -------------------------------------------------------------------
type DateOptions struct {
Min types.DateTime `form:"min" json:"min"`
Max types.DateTime `form:"max" json:"max"`
@@ -555,9 +597,21 @@ func (o FileOptions) Validate() error {
// -------------------------------------------------------------------
type RelationOptions struct {
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
CollectionId string `form:"collectionId" json:"collectionId"`
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
// CollectionId is the id of the related collection.
CollectionId string `form:"collectionId" json:"collectionId"`
// CascadeDelete indicates whether the root model should be deleted
// in case of delete of all linked relations.
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
// MaxSelect indicates the max number of allowed relation records
// that could be linked to the main model.
//
// If nil no limits are applied.
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
// DisplayFields is optional slice of collection field names used for UI purposes.
DisplayFields []string `form:"displayFields" json:"displayFields"`
}
func (o RelationOptions) Validate() error {
+83 -2
View File
@@ -40,7 +40,7 @@ func TestAuthFieldNames(t *testing.T) {
func TestFieldTypes(t *testing.T) {
result := schema.FieldTypes()
expected := 10
expected := 11
if len(result) != expected {
t.Fatalf("Expected %d types, got %d (%v)", expected, len(result), result)
@@ -81,6 +81,10 @@ func TestSchemaFieldColDefinition(t *testing.T) {
schema.SchemaField{Type: schema.FieldTypeUrl, Name: "test"},
"TEXT DEFAULT ''",
},
{
schema.SchemaField{Type: schema.FieldTypeEditor, Name: "test"},
"TEXT DEFAULT ''",
},
{
schema.SchemaField{Type: schema.FieldTypeDate, Name: "test"},
"TEXT DEFAULT ''",
@@ -477,6 +481,11 @@ func TestSchemaFieldInitOptions(t *testing.T) {
false,
`{"system":false,"id":"","name":"","type":"url","required":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeEditor},
false,
`{"system":false,"id":"","name":"","type":"editor","required":false,"unique":false,"options":{}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeDate},
false,
@@ -500,7 +509,7 @@ func TestSchemaFieldInitOptions(t *testing.T) {
{
schema.SchemaField{Type: schema.FieldTypeRelation},
false,
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"maxSelect":null,"collectionId":"","cascadeDelete":false}}`,
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"collectionId":"","cascadeDelete":false,"maxSelect":null,"displayFields":null}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeUser},
@@ -562,12 +571,38 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeUrl}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, 123, `"123"`},
// editor
{schema.SchemaField{Type: schema.FieldTypeEditor}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeEditor}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeEditor}, []int{1, 2}, `""`},
{schema.SchemaField{Type: schema.FieldTypeEditor}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeEditor}, 123, `"123"`},
// json
{schema.SchemaField{Type: schema.FieldTypeJson}, nil, "null"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "null", "null"},
{schema.SchemaField{Type: schema.FieldTypeJson}, 123, "123"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "123", "123"},
{schema.SchemaField{Type: schema.FieldTypeJson}, 123.456, "123.456"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "123.456", "123.456"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "123.456 abc", `"123.456 abc"`}, // invalid numeric string
{schema.SchemaField{Type: schema.FieldTypeJson}, true, "true"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "true", "true"},
{schema.SchemaField{Type: schema.FieldTypeJson}, false, "false"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "false", "false"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeJson}, `test`, `"test"`},
{schema.SchemaField{Type: schema.FieldTypeJson}, `"test"`, `"test"`},
{schema.SchemaField{Type: schema.FieldTypeJson}, `{test":1}`, `"{test\":1}"`}, // invalid object string
{schema.SchemaField{Type: schema.FieldTypeJson}, `[1 2 3]`, `"[1 2 3]"`}, // invalid array string
{schema.SchemaField{Type: schema.FieldTypeJson}, map[string]int{}, `{}`},
{schema.SchemaField{Type: schema.FieldTypeJson}, `{}`, `{}`},
{schema.SchemaField{Type: schema.FieldTypeJson}, map[string]int{"test": 123}, `{"test":123}`},
{schema.SchemaField{Type: schema.FieldTypeJson}, `{"test":123}`, `{"test":123}`},
{schema.SchemaField{Type: schema.FieldTypeJson}, []int{}, `[]`},
{schema.SchemaField{Type: schema.FieldTypeJson}, `[]`, `[]`},
{schema.SchemaField{Type: schema.FieldTypeJson}, []int{1, 2, 1}, `[1,2,1]`},
{schema.SchemaField{Type: schema.FieldTypeJson}, `[1,2,1]`, `[1,2,1]`},
// number
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "0"},
@@ -1042,6 +1077,40 @@ func TestSchemaFieldPrepareValueWithModifier(t *testing.T) {
`"123"`,
},
// editor
{
"editor with '+' modifier",
schema.SchemaField{Type: schema.FieldTypeEditor},
"base",
"+",
"new",
`"base"`,
},
{
"editor with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeEditor},
"base",
"-",
"new",
`"base"`,
},
{
"editor with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeEditor},
"base",
"?",
"new",
`"base"`,
},
{
"editor cast check",
schema.SchemaField{Type: schema.FieldTypeEditor},
123,
"-",
"new",
`"123"`,
},
// date
{
"date with '+' modifier",
@@ -1797,6 +1866,18 @@ func TestUrlOptionsValidate(t *testing.T) {
checkFieldOptionsScenarios(t, scenarios)
}
func TestEditorOptionsValidate(t *testing.T) {
scenarios := []fieldOptionsScenario{
{
"empty",
schema.EditorOptions{},
[]string{},
},
}
checkFieldOptionsScenarios(t, scenarios)
}
func TestDateOptionsValidate(t *testing.T) {
date1 := types.NowDateTime()
date2, _ := types.ParseDateTime(date1.Time().AddDate(1, 0, 0))
+21
View File
@@ -46,6 +46,9 @@ type Settings struct {
TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
LivechatAuth AuthProviderConfig `form:"livechatAuth" json:"livechatAuth"`
AuthentikAuth AuthProviderConfig `form:"authentikAuth" json:"authentikAuth"`
GiteaAuth AuthProviderConfig `form:"giteaAuth" json:"giteaAuth"`
}
// New creates and returns a new default Settings instance.
@@ -132,6 +135,15 @@ func New() *Settings {
GiteeAuth: AuthProviderConfig{
Enabled: false,
},
LivechatAuth: AuthProviderConfig{
Enabled: false,
},
AuthentikAuth: AuthProviderConfig{
Enabled: false,
},
GiteaAuth: AuthProviderConfig{
Enabled: false,
},
}
}
@@ -163,6 +175,9 @@ func (s *Settings) Validate() error {
validation.Field(&s.TwitchAuth),
validation.Field(&s.StravaAuth),
validation.Field(&s.GiteeAuth),
validation.Field(&s.LivechatAuth),
validation.Field(&s.AuthentikAuth),
validation.Field(&s.GiteaAuth),
)
}
@@ -219,6 +234,9 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.TwitchAuth.ClientSecret,
&clone.StravaAuth.ClientSecret,
&clone.GiteeAuth.ClientSecret,
&clone.LivechatAuth.ClientSecret,
&clone.AuthentikAuth.ClientSecret,
&clone.GiteaAuth.ClientSecret,
}
// mask all sensitive fields
@@ -250,6 +268,9 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
auth.NameTwitch: s.TwitchAuth,
auth.NameStrava: s.StravaAuth,
auth.NameGitee: s.GiteeAuth,
auth.NameLivechat: s.LivechatAuth,
auth.NameAuthentik: s.AuthentikAuth,
auth.NameGitea: s.GiteaAuth,
}
}
+25 -1
View File
@@ -52,6 +52,12 @@ func TestSettingsValidate(t *testing.T) {
s.StravaAuth.ClientId = ""
s.GiteeAuth.Enabled = true
s.GiteeAuth.ClientId = ""
s.LivechatAuth.Enabled = true
s.LivechatAuth.ClientId = ""
s.AuthentikAuth.Enabled = true
s.AuthentikAuth.ClientId = ""
s.GiteaAuth.Enabled = true
s.GiteaAuth.ClientId = ""
// check if Validate() is triggering the members validate methods.
err := s.Validate()
@@ -82,6 +88,9 @@ func TestSettingsValidate(t *testing.T) {
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"authentikAuth":{`,
`"giteaAuth":{`,
}
errBytes, _ := json.Marshal(err)
@@ -134,6 +143,12 @@ func TestSettingsMerge(t *testing.T) {
s2.StravaAuth.ClientId = "strava_test"
s2.GiteeAuth.Enabled = true
s2.GiteeAuth.ClientId = "gitee_test"
s2.LivechatAuth.Enabled = true
s2.LivechatAuth.ClientId = "livechat_test"
s2.AuthentikAuth.Enabled = true
s2.AuthentikAuth.ClientId = "authentik_test"
s2.GiteaAuth.Enabled = true
s2.GiteaAuth.ClientId = "gitea_test"
if err := s1.Merge(s2); err != nil {
t.Fatal(err)
@@ -207,6 +222,9 @@ func TestSettingsRedactClone(t *testing.T) {
s1.TwitchAuth.ClientSecret = "test123"
s1.StravaAuth.ClientSecret = "test123"
s1.GiteeAuth.ClientSecret = "test123"
s1.LivechatAuth.ClientSecret = "test123"
s1.AuthentikAuth.ClientSecret = "test123"
s1.GiteaAuth.ClientSecret = "test123"
s2, err := s1.RedactClone()
if err != nil {
@@ -218,7 +236,7 @@ func TestSettingsRedactClone(t *testing.T) {
t.Fatal(err)
}
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","authMethod":"","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"},"stravaAuth":{"enabled":false,"clientSecret":"******"},"giteeAuth":{"enabled":false,"clientSecret":"******"}}`
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","authMethod":"","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"},"stravaAuth":{"enabled":false,"clientSecret":"******"},"giteeAuth":{"enabled":false,"clientSecret":"******"},"livechatAuth":{"enabled":false,"clientSecret":"******"},"authentikAuth":{"enabled":false,"clientSecret":"******"},"giteaAuth":{"enabled":false,"clientSecret":"******"}}`
if encodedStr := string(encoded); encodedStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr)
@@ -241,6 +259,9 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
s.TwitchAuth.ClientId = "twitch_test"
s.StravaAuth.ClientId = "strava_test"
s.GiteeAuth.ClientId = "gitee_test"
s.LivechatAuth.ClientId = "livechat_test"
s.AuthentikAuth.ClientId = "authentik_test"
s.GiteaAuth.ClientId = "gitea_test"
result := s.NamedAuthProviderConfigs()
@@ -263,6 +284,9 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
`"twitch":{"enabled":false,"clientId":"twitch_test"}`,
`"strava":{"enabled":false,"clientId":"strava_test"}`,
`"gitee":{"enabled":false,"clientId":"gitee_test"}`,
`"livechat":{"enabled":false,"clientId":"livechat_test"}`,
`"authentik":{"enabled":false,"clientId":"authentik_test"}`,
`"gitea":{"enabled":false,"clientId":"gitea_test"}`,
}
for _, p := range expectedParts {
if !strings.Contains(encodedStr, p) {
+2 -2
View File
@@ -348,11 +348,11 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// allow querying only auth records with emails marked as public
if prop == schema.FieldNameEmail && !r.allowHiddenFields {
result.AfterBuild = func(expr dbx.Expression) dbx.Expression {
return dbx.And(expr, dbx.NewExp(fmt.Sprintf(
return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
"[[%s.%s]] = TRUE",
r.activeTableAlias,
schema.FieldNameEmailVisibility,
)))
))))
}
}
File diff suppressed because one or more lines are too long
+56
View File
@@ -182,6 +182,30 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return t.registerEventCall("OnRecordAuthRequest")
})
t.OnRecordBeforeAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
return t.registerEventCall("OnRecordBeforeAuthWithPasswordRequest")
})
t.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
return t.registerEventCall("OnRecordAfterAuthWithPasswordRequest")
})
t.OnRecordBeforeAuthWithOAuth2Request().Add(func(e *core.RecordAuthWithOAuth2Event) error {
return t.registerEventCall("OnRecordBeforeAuthWithOAuth2Request")
})
t.OnRecordAfterAuthWithOAuth2Request().Add(func(e *core.RecordAuthWithOAuth2Event) error {
return t.registerEventCall("OnRecordAfterAuthWithOAuth2Request")
})
t.OnRecordBeforeAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
return t.registerEventCall("OnRecordBeforeAuthRefreshRequest")
})
t.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
return t.registerEventCall("OnRecordAfterAuthRefreshRequest")
})
t.OnRecordBeforeRequestPasswordResetRequest().Add(func(e *core.RecordRequestPasswordResetEvent) error {
return t.registerEventCall("OnRecordBeforeRequestPasswordResetRequest")
})
@@ -386,6 +410,38 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return t.registerEventCall("OnAdminAuthRequest")
})
t.OnAdminBeforeAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
return t.registerEventCall("OnAdminBeforeAuthWithPasswordRequest")
})
t.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
return t.registerEventCall("OnAdminAfterAuthWithPasswordRequest")
})
t.OnAdminBeforeAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
return t.registerEventCall("OnAdminBeforeAuthRefreshRequest")
})
t.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
return t.registerEventCall("OnAdminAfterAuthRefreshRequest")
})
t.OnAdminBeforeRequestPasswordResetRequest().Add(func(e *core.AdminRequestPasswordResetEvent) error {
return t.registerEventCall("OnAdminBeforeRequestPasswordResetRequest")
})
t.OnAdminAfterRequestPasswordResetRequest().Add(func(e *core.AdminRequestPasswordResetEvent) error {
return t.registerEventCall("OnAdminAfterRequestPasswordResetRequest")
})
t.OnAdminBeforeConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
return t.registerEventCall("OnAdminBeforeConfirmPasswordResetRequest")
})
t.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
return t.registerEventCall("OnAdminAfterConfirmPasswordResetRequest")
})
t.OnFileDownloadRequest().Add(func(e *core.FileDownloadEvent) error {
return t.registerEventCall("OnFileDownloadRequest")
})
Binary file not shown.
+6
View File
@@ -110,6 +110,12 @@ func NewProviderByName(name string) (Provider, error) {
return NewStravaProvider(), nil
case NameGitee:
return NewGiteeProvider(), nil
case NameLivechat:
return NewLivechatProvider(), nil
case NameAuthentik:
return NewAuthentikProvider(), nil
case NameGitea:
return NewGiteaProvider(), nil
default:
return nil, errors.New("Missing provider " + name)
}
+27
View File
@@ -126,4 +126,31 @@ func TestNewProviderByName(t *testing.T) {
if _, ok := p.(*auth.Gitee); !ok {
t.Error("Expected to be instance of *auth.Gitee")
}
// livechat
p, err = auth.NewProviderByName(auth.NameLivechat)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Livechat); !ok {
t.Error("Expected to be instance of *auth.Livechat")
}
// authentik
p, err = auth.NewProviderByName(auth.NameAuthentik)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Authentik); !ok {
t.Error("Expected to be instance of *auth.Authentik")
}
// gitea
p, err = auth.NewProviderByName(auth.NameGitea)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Gitea); !ok {
t.Error("Expected to be instance of *auth.Gitea")
}
}
+71
View File
@@ -0,0 +1,71 @@
package auth
import (
"encoding/json"
"golang.org/x/oauth2"
)
var _ Provider = (*Authentik)(nil)
// NameAuthentik is the unique name of the Authentik provider.
const NameAuthentik string = "authentik"
// Authentik allows authentication via Authentik OAuth2.
type Authentik struct {
*baseProvider
}
// NewAuthentikProvider creates new Authentik provider instance with some defaults.
func NewAuthentikProvider() *Authentik {
return &Authentik{&baseProvider{
scopes: []string{
"openid", // minimal requirement to return the id
"email",
"profile",
},
}}
}
// FetchAuthUser returns an AuthUser instance based the Authentik's user api.
//
// API reference: https://goauthentik.io/docs/providers/oauth2/
func (p *Authentik) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"sub"`
Name string `json:"name"`
Username string `json:"preferred_username"`
Picture string `json:"picture"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
Username: extracted.Username,
AvatarUrl: extracted.Picture,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
if extracted.EmailVerified {
user.Email = extracted.Email
}
return user, nil
}
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"encoding/json"
"strconv"
"golang.org/x/oauth2"
)
var _ Provider = (*Gitea)(nil)
// NameGitea is the unique name of the Gitea provider.
const NameGitea string = "gitea"
// Gitea allows authentication via Gitea OAuth2.
type Gitea struct {
*baseProvider
}
// NewGiteaProvider creates new Gitea provider instance with some defaults.
func NewGiteaProvider() *Gitea {
return &Gitea{&baseProvider{
scopes: []string{"read:user", "user:email"},
authUrl: "https://gitea.com/login/oauth/authorize",
tokenUrl: "https://gitea.com/login/oauth/access_token",
userApiUrl: "https://gitea.com/api/v1/user",
}}
}
// FetchAuthUser returns an AuthUser instance based on Gitea's user api.
//
// API reference: https://try.gitea.io/api/swagger#/user/userGetCurrent
func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id int `json:"id"`
Name string `json:"full_name"`
Username string `json:"login"`
Email string `json:"email"`
AvatarUrl string `json:"avatar_url"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Id),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarUrl: extracted.AvatarUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
return user, nil
}
+68
View File
@@ -0,0 +1,68 @@
package auth
import (
"encoding/json"
"golang.org/x/oauth2"
)
var _ Provider = (*Livechat)(nil)
// NameLivechat is the unique name of the Livechat provider.
const NameLivechat = "livechat"
// Livechat allows authentication via Livechat OAuth2.
type Livechat struct {
*baseProvider
}
// NewLivechatProvider creates new Livechat provider instance with some defaults.
func NewLivechatProvider() *Livechat {
return &Livechat{&baseProvider{
scopes: []string{}, // default scopes are specified from the provider dashboard
authUrl: "https://accounts.livechat.com/",
tokenUrl: "https://accounts.livechat.com/token",
userApiUrl: "https://accounts.livechat.com/v2/accounts/me",
}}
}
// FetchAuthUser returns an AuthUser based on the Livechat accounts API.
//
// API reference: https://developers.livechat.com/docs/authorization
func (p *Livechat) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"account_id"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
AvatarUrl string `json:"avatar_url"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
AvatarUrl: extracted.AvatarUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
if extracted.EmailVerified {
user.Email = extracted.Email
}
return user, nil
}
+4 -1
View File
@@ -58,7 +58,6 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Id),
Name: extracted.FirstName + " " + extracted.LastName,
Username: extracted.Username,
AvatarUrl: extracted.ProfileImageUrl,
@@ -67,5 +66,9 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
RefreshToken: token.RefreshToken,
}
if extracted.Id != 0 {
user.Id = strconv.Itoa(extracted.Id)
}
return user, nil
}
+17 -7
View File
@@ -98,6 +98,18 @@ func (s *System) Attributes(fileKey string) (*blob.Attributes, error) {
return s.bucket.Attributes(s.ctx, fileKey)
}
// GetFile returns a file content reader for the given fileKey.
//
// NB! Make sure to call `Close()` after you are done working with it.
func (s *System) GetFile(fileKey string) (io.ReadCloser, error) {
br, err := s.bucket.NewReader(s.ctx, fileKey, nil)
if err != nil {
return nil, err
}
return br, nil
}
// Upload writes content into the fileKey location.
func (s *System) Upload(content []byte, fileKey string) error {
opts := &blob.WriterOptions{
@@ -225,13 +237,11 @@ func (s *System) DeletePrefix(prefix string) []error {
})
for {
obj, err := iter.Next(s.ctx)
if err == io.EOF {
break
}
if err != nil {
failed = append(failed, err)
continue
if err != io.EOF {
failed = append(failed, err)
}
break
}
if err := s.Delete(obj.Key); err != nil {
@@ -300,7 +310,7 @@ func (s *System) Serve(res http.ResponseWriter, req *http.Request, fileKey strin
}
// make an exception for specific content types and force a custom
// content type to send in the response so that it can be loaded directly
// content type to send in the response so that it can be loaded properly
extContentType := realContentType
if ct, found := manualExtensionContentTypes[filepath.Ext(name)]; found && extContentType != ct {
extContentType = ct
+21
View File
@@ -349,6 +349,27 @@ func TestFileSystemServe(t *testing.T) {
}
}
func TestFileSystemGetFile(t *testing.T) {
dir := createTestDir(t)
defer os.RemoveAll(dir)
fs, err := filesystem.NewLocal(dir)
if err != nil {
t.Fatal(err)
}
defer fs.Close()
f, err := fs.GetFile("image.png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if f == nil {
t.Fatal("File is supposed to be found")
}
}
func TestFileSystemServeSingleRange(t *testing.T) {
dir := createTestDir(t)
defer os.RemoveAll(dir)
+4 -4
View File
@@ -5,7 +5,7 @@ import (
"testing"
)
func TestAddAndPreAdd(t *testing.T) {
func TestHookAddAndPreAdd(t *testing.T) {
h := Hook[int]{}
if total := len(h.handlers); total != 0 {
@@ -36,7 +36,7 @@ func TestAddAndPreAdd(t *testing.T) {
}
}
func TestReset(t *testing.T) {
func TestHookReset(t *testing.T) {
h := Hook[int]{}
h.Reset() // should do nothing and not panic
@@ -55,7 +55,7 @@ func TestReset(t *testing.T) {
}
}
func TestTrigger(t *testing.T) {
func TestHookTrigger(t *testing.T) {
err1 := errors.New("demo")
err2 := errors.New("demo")
@@ -92,7 +92,7 @@ func TestTrigger(t *testing.T) {
}
}
func TestTriggerStopPropagation(t *testing.T) {
func TestHookTriggerStopPropagation(t *testing.T) {
called1 := false
f1 := func(data int) error { called1 = true; return nil }
+74
View File
@@ -0,0 +1,74 @@
package hook
import (
"github.com/pocketbase/pocketbase/tools/list"
)
// Tagger defines an interface for event data structs that support tags/groups/categories/etc.
// Usually used together with TaggedHook.
type Tagger interface {
Tags() []string
}
// wrapped local Hook embedded struct to limit the public API surface.
type mainHook[T Tagger] struct {
*Hook[T]
}
// NewTaggedHook creates a new TaggedHook with the provided main hook and optional tags.
func NewTaggedHook[T Tagger](hook *Hook[T], tags ...string) *TaggedHook[T] {
return &TaggedHook[T]{
mainHook[T]{hook},
tags,
}
}
// TaggedHook defines a proxy hook which register handlers that are triggered only
// if the TaggedHook.tags are empty or includes at least one of the event data tag(s).
type TaggedHook[T Tagger] struct {
mainHook[T]
tags []string
}
// CanTriggerOn checks if the current TaggedHook can be triggered with
// the provided event data tags.
func (h *TaggedHook[T]) CanTriggerOn(tags []string) bool {
if len(h.tags) == 0 {
return true // match all
}
for _, t := range tags {
if list.ExistInSlice(t, h.tags) {
return true
}
}
return false
}
// PreAdd registers a new handler to the hook by prepending it to the existing queue.
//
// The fn handler will be called only if the event data tags satisfy h.CanTriggerOn.
func (h *TaggedHook[T]) PreAdd(fn Handler[T]) {
h.mainHook.PreAdd(func(e T) error {
if h.CanTriggerOn(e.Tags()) {
return fn(e)
}
return nil
})
}
// Add registers a new handler to the hook by appending it to the existing queue.
//
// The fn handler will be called only if the event data tags satisfy h.CanTriggerOn.
func (h *TaggedHook[T]) Add(fn Handler[T]) {
h.mainHook.Add(func(e T) error {
if h.CanTriggerOn(e.Tags()) {
return fn(e)
}
return nil
})
}
+69
View File
@@ -0,0 +1,69 @@
package hook
import "testing"
type mockTagsData struct {
tags []string
}
func (m mockTagsData) Tags() []string {
return m.tags
}
func TestTaggedHook(t *testing.T) {
triggerSequence := ""
base := &Hook[mockTagsData]{}
base.Add(func(data mockTagsData) error { triggerSequence += "f0"; return nil })
hA := NewTaggedHook(base)
hA.Add(func(data mockTagsData) error { triggerSequence += "a1"; return nil })
hA.PreAdd(func(data mockTagsData) error { triggerSequence += "a2"; return nil })
hB := NewTaggedHook(base, "b1", "b2")
hB.Add(func(data mockTagsData) error { triggerSequence += "b1"; return nil })
hB.PreAdd(func(data mockTagsData) error { triggerSequence += "b2"; return nil })
hC := NewTaggedHook(base, "c1", "c2")
hC.Add(func(data mockTagsData) error { triggerSequence += "c1"; return nil })
hC.PreAdd(func(data mockTagsData) error { triggerSequence += "c2"; return nil })
scenarios := []struct {
data mockTagsData
expectedSequence string
}{
{
mockTagsData{},
"a2f0a1",
},
{
mockTagsData{[]string{"missing"}},
"a2f0a1",
},
{
mockTagsData{[]string{"b2"}},
"b2a2f0a1b1",
},
{
mockTagsData{[]string{"c1"}},
"c2a2f0a1c1",
},
{
mockTagsData{[]string{"b1", "c2"}},
"c2b2a2f0a1b1c1",
},
}
for i, s := range scenarios {
triggerSequence = "" // reset
err := hA.Trigger(s.data)
if err != nil {
t.Fatalf("[%d] Unexpected trigger error: %v", i, err)
}
if triggerSequence != s.expectedSequence {
t.Fatalf("[%d] Expected trigger sequence %s, got %s", i, s.expectedSequence, triggerSequence)
}
}
}
+4 -4
View File
@@ -147,7 +147,7 @@ func buildExpr(
op: op,
}
expr = dbx.And(expr, mm)
expr = dbx.Enclose(dbx.And(expr, mm))
} else if left.MultiMatchSubQuery != nil {
mm := &manyVsOneExpr{
subQuery: left.MultiMatchSubQuery,
@@ -155,7 +155,7 @@ func buildExpr(
otherOperand: right,
}
expr = dbx.And(expr, mm)
expr = dbx.Enclose(dbx.And(expr, mm))
} else if right.MultiMatchSubQuery != nil {
mm := &manyVsOneExpr{
subQuery: right.MultiMatchSubQuery,
@@ -164,7 +164,7 @@ func buildExpr(
inverse: true,
}
expr = dbx.And(expr, mm)
expr = dbx.Enclose(dbx.And(expr, mm))
}
}
@@ -473,6 +473,6 @@ func multiMatchAfterBuildFunc(op fexpr.SignOp, multiMatchAliases ...string) func
orExprs[i+1] = dbx.NewExp("[[" + mAlias + ".multiMatchValue]] IS NULL")
}
return dbx.Or(orExprs...)
return dbx.Enclose(dbx.Or(orExprs...))
}
}
+43 -2
View File
@@ -8,9 +8,36 @@ type Store[T any] struct {
data map[string]T
}
// New creates a new Store[T] instance.
// New creates a new Store[T] instance with a shallow copy of the provided data (if any).
func New[T any](data map[string]T) *Store[T] {
return &Store[T]{data: data}
s := &Store[T]{}
s.Reset(data)
return s
}
// Reset clears the store and replaces the store data with a
// shallow copy of the provided newData.
func (s *Store[T]) Reset(newData map[string]T) {
s.mux.Lock()
defer s.mux.Unlock()
var clone = make(map[string]T, len(newData))
for k, v := range newData {
clone[k] = v
}
s.data = clone
}
// Length returns the current number of elements in the store.
func (s *Store[T]) Length() int {
s.mux.RLock()
defer s.mux.RUnlock()
return len(s.data)
}
// RemoveAll removes all the existing store entries.
@@ -51,6 +78,20 @@ func (s *Store[T]) Get(key string) T {
return s.data[key]
}
// GetAll returns a shallow copy of the current store data.
func (s *Store[T]) GetAll() map[string]T {
s.mux.RLock()
defer s.mux.RUnlock()
var clone = make(map[string]T, len(s.data))
for k, v := range s.data {
clone[k] = v
}
return clone
}
// Set sets (or overwrite if already exist) a new value for key.
func (s *Store[T]) Set(key string, value T) {
s.mux.Lock()
+97 -5
View File
@@ -1,16 +1,80 @@
package store_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/pocketbase/pocketbase/tools/store"
)
func TestNew(t *testing.T) {
s := store.New(map[string]int{"test": 1})
data := map[string]int{"test1": 1, "test2": 2}
originalRawData, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
if s.Get("test") != 1 {
t.Error("Expected the initizialized store map to be loaded")
s := store.New(data)
s.Set("test3", 3) // add 1 item
s.Remove("test1") // remove 1 item
// check if data was shallow copied
rawData, _ := json.Marshal(data)
if !bytes.Equal(originalRawData, rawData) {
t.Fatalf("Expected data \n%s, \ngot \n%s", originalRawData, rawData)
}
if s.Has("test1") {
t.Fatalf("Expected test1 to be deleted, got %v", s.Get("test1"))
}
if v := s.Get("test2"); v != 2 {
t.Fatalf("Expected test2 to be %v, got %v", 2, v)
}
if v := s.Get("test3"); v != 3 {
t.Fatalf("Expected test3 to be %v, got %v", 3, v)
}
}
func TestReset(t *testing.T) {
s := store.New(map[string]int{"test1": 1})
data := map[string]int{"test2": 2}
originalRawData, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
s.Reset(data)
s.Set("test3", 3)
// check if data was shallow copied
rawData, _ := json.Marshal(data)
if !bytes.Equal(originalRawData, rawData) {
t.Fatalf("Expected data \n%s, \ngot \n%s", originalRawData, rawData)
}
if s.Has("test1") {
t.Fatalf("Expected test1 to be deleted, got %v", s.Get("test1"))
}
if v := s.Get("test2"); v != 2 {
t.Fatalf("Expected test2 to be %v, got %v", 2, v)
}
if v := s.Get("test3"); v != 3 {
t.Fatalf("Expected test3 to be %v, got %v", 3, v)
}
}
func TestLength(t *testing.T) {
s := store.New(map[string]int{"test1": 1})
s.Set("test2", 2)
if v := s.Length(); v != 2 {
t.Fatalf("Expected length %d, got %d", 2, v)
}
}
@@ -81,8 +145,36 @@ func TestGet(t *testing.T) {
}
}
func TestGetAll(t *testing.T) {
data := map[string]int{
"a": 1,
"b": 2,
}
s := store.New(data)
// fetch and delete each key to make sure that it was shallow copied
result := s.GetAll()
for k := range result {
delete(result, k)
}
// refetch again
result = s.GetAll()
if len(result) != len(data) {
t.Fatalf("Expected %d, got %d items", len(data), len(result))
}
for k := range result {
if result[k] != data[k] {
t.Fatalf("Expected %s to be %v, got %v", k, data[k], result[k])
}
}
}
func TestSet(t *testing.T) {
s := store.New[int](nil)
s := store.Store[int]{}
data := map[string]int{"test1": 0, "test2": 1, "test3": 3}
@@ -105,7 +197,7 @@ func TestSet(t *testing.T) {
}
func TestSetIfLessThanLimit(t *testing.T) {
s := store.New[int](nil)
s := store.Store[int]{}
limit := 2
+13 -7
View File
@@ -18,12 +18,19 @@ func NewBroker() *Broker {
}
}
// Clients returns all registered clients.
// Clients returns a shallow copy of all registered clients indexed
// with their connection id.
func (b *Broker) Clients() map[string]Client {
b.mux.RLock()
defer b.mux.RUnlock()
return b.clients
copy := make(map[string]Client, len(b.clients))
for id, c := range b.clients {
copy[id] = c
}
return copy
}
// ClientById finds a registered client by its id.
@@ -56,9 +63,8 @@ func (b *Broker) Unregister(clientId string) {
b.mux.Lock()
defer b.mux.Unlock()
// Note:
// There is no need to explicitly close the client's channel since it will be GC-ed anyway.
// Addinitionally, closing the channel explicitly could panic when there are several
// subscriptions attached to the client that needs to receive the same event.
delete(b.clients, clientId)
if client, ok := b.clients[clientId]; ok {
client.Discard()
delete(b.clients, clientId)
}
}
+7
View File
@@ -24,6 +24,13 @@ func TestClients(t *testing.T) {
b.Register(subscriptions.NewDefaultClient())
b.Register(subscriptions.NewDefaultClient())
// check if it is a shallow copy
clients := b.Clients()
for k := range clients {
delete(clients, k)
}
// should return a new copy
if total := len(b.Clients()); total != 2 {
t.Fatalf("Expected 2 clients, got %v", total)
}
+33
View File
@@ -37,6 +37,16 @@ type Client interface {
// Get retrieves the key value from the client's context.
Get(key string) any
// Discard marks the client as "discarded", meaning that it
// shouldn't be used anymore for sending new messages.
//
// It is safe to call Discard() multiple times.
Discard()
// IsDiscarded indicates whether the client has been "discarded"
// and should no longer be used.
IsDiscarded() bool
}
// ensures that DefaultClient satisfies the Client interface
@@ -45,6 +55,7 @@ var _ Client = (*DefaultClient)(nil)
// DefaultClient defines a generic subscription client.
type DefaultClient struct {
mux sync.RWMutex
isDiscarded bool
id string
store map[string]any
channel chan Message
@@ -63,11 +74,17 @@ func NewDefaultClient() *DefaultClient {
// Id implements the [Client.Id] interface method.
func (c *DefaultClient) Id() string {
c.mux.RLock()
defer c.mux.RUnlock()
return c.id
}
// Channel implements the [Client.Channel] interface method.
func (c *DefaultClient) Channel() chan Message {
c.mux.RLock()
defer c.mux.RUnlock()
return c.channel
}
@@ -139,3 +156,19 @@ func (c *DefaultClient) Set(key string, value any) {
c.store[key] = value
}
// Discard implements the [Client.Discard] interface method.
func (c *DefaultClient) Discard() {
c.mux.Lock()
defer c.mux.Unlock()
c.isDiscarded = true
}
// IsDiscarded implements the [Client.IsDiscarded] interface method.
func (c *DefaultClient) IsDiscarded() bool {
c.mux.RLock()
defer c.mux.RUnlock()
return c.isDiscarded
}
+14
View File
@@ -129,3 +129,17 @@ func TestSetAndGet(t *testing.T) {
t.Errorf("Expected 1, got %v", result)
}
}
func TestDiscard(t *testing.T) {
c := subscriptions.NewDefaultClient()
if v := c.IsDiscarded(); v {
t.Fatal("Expected false, got true")
}
c.Discard()
if v := c.IsDiscarded(); !v {
t.Fatal("Expected true, got false")
}
}
+1
View File
@@ -179,6 +179,7 @@ func TestDateTimeScan(t *testing.T) {
{"invalid", ""},
{types.NowDateTime(), now},
{time.Now(), now},
{1.0, ""},
{1641024040, "2022-01-01 08:00:40.000Z"},
{"2022-01-01 11:23:45.678", "2022-01-01 11:23:45.678Z"},
}

Some files were not shown because too many files have changed in this diff Show More