Compare commits

...
144 Commits
Author SHA1 Message Date
Gani Georgiev af34eb544d [#1989] fixed RecordInfo multiple files preview 2023-03-06 13:33:15 +02:00
Gani Georgiev 42e288c71a [#1976] added HEAD requests support for the file download action 2023-03-05 15:39:18 +02:00
Gani Georgiev dc96a12dc0 updated ui/dist 2023-02-26 11:00:52 +02:00
Gani Georgiev 0bb58eb52c minor UI fixes 2023-02-26 10:47:00 +02:00
Gani Georgiev cf6d325add added model query autocancellation test 2023-02-24 22:07:25 +02:00
Gani Georgiev 578e1c9bc1 [#223] updated the internal redirects to allow easier subpath deployment when behind a reverse proxy 2023-02-24 18:49:46 +02:00
Gani Georgiev 4778fc7a46 added min select relation field option 2023-02-24 16:34:02 +02:00
Gani Georgiev f1a6a82bd3 prevent collectionId relation field change 2023-02-24 14:12:27 +02:00
Gani Georgiev 21b152b58c fixed formatting and typos 2023-02-23 21:51:42 +02:00
Gani Georgiev aa4e405f92 replaced authentik with generic oidc provider 2023-02-23 21:07:00 +02:00
Gani Georgiev e529fe7e2a added --queryTimeout flag 2023-02-23 18:59:23 +02:00
Gani Georgiev 6ab2fa9489 updated default query timeout to 1m 2023-02-23 12:04:16 +02:00
Gani Georgiev 010a396b0e updated dao fail/retry handling 2023-02-22 22:20:19 +02:00
Gani Georgiev 65a148b741 added UploadedFiles to the record create/update events 2023-02-22 22:09:13 +02:00
Gani Georgiev 503c65a767 [#1896] run files upload after record save 2023-02-22 22:06:59 +02:00
Gani Georgiev eec3261d67 added cancelKey to the RecordsList component 2023-02-22 18:02:08 +02:00
Gani Georgiev 0db6c783cd minor ui improvements 2023-02-21 22:24:49 +02:00
Gani Georgiev 4fdc8feafc updated daos.HasTable to check also for views 2023-02-21 20:03:27 +02:00
Gani Georgiev 41c3cc8a90 added select auto fail/retry 2023-02-21 16:54:08 +02:00
Gani Georgiev 0afb09b3bd updated view ui 2023-02-21 16:32:58 +02:00
Gani Georgiev 1075292321 fixed godoc formatting 2023-02-20 13:31:03 +02:00
Gani Georgiev b184ef6c3a fixed daos.SaveView test 2023-02-18 20:31:41 +02:00
Gani Georgiev 5fd7f61656 temporary change the github action's go version to 1.19 2023-02-18 20:06:22 +02:00
Gani Georgiev 851711e231 updated go deps 2023-02-18 20:02:47 +02:00
Gani Georgiev a07f67002f added view collection type 2023-02-18 19:33:42 +02:00
Gani Georgiev 0052e2ab2a synced with master 2023-02-12 14:11:12 +02:00
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 fa65a20202 updated changelog 2023-02-06 19:57:43 +02:00
Gani Georgiev 668698feb2 [#1797] enabled goja/process module for accessing the os.Environ 2023-02-06 19:53:21 +02:00
Gani Georgiev f475967a4a updated default tokenizer separators 2023-02-06 16:30:47 +02:00
Gani Georgiev 23dfa9c634 added generic tokenizer helper 2023-02-05 20:59:17 +02:00
Gani Georgiev 1b21e86be6 synced with master 2023-02-02 15:35:03 +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 7d38971850 updated changelog example 2023-02-01 22:19:36 +02:00
Gani Georgiev 15102138c3 updated changelog 2023-02-01 22:10:00 +02:00
Gani Georgiev 2378bc72c5 [#1728] normalized mailer.Message recipient fields 2023-02-01 22:07:46 +02:00
Gani Georgiev 69b80123de Merge branch 'master' into develop 2023-01-30 17:26:40 +02:00
Gani Georgiev 8b93aac8e3 unregister closed/destroyed overlay panels 2023-01-30 16:31:19 +02:00
Gani Georgiev 250642a8f9 allowed overwriting the default file serve headers if an explicit response header is set 2023-01-30 12:54:51 +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 a8b2f0f6f1 updated min github action go version to 1.19.5 2023-01-11 14:53:24 +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
Gani Georgiev f7d4722052 [#1552] unescaped path parameter values 2023-01-09 22:36:28 +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
Gani Georgiev 3dc1bf6fa7 updated go deps 2023-01-08 11:32:19 +02:00
Gani Georgiev d3cc87abee updated rule field styles 2023-01-08 11:11:02 +02:00
Gani Georgiev 19ad827302 updated migration timestamp 2023-01-08 11:05:44 +02:00
Gani Georgiev 40830b6c43 updated API preview docs 2023-01-08 10:31:21 +02:00
Gani Georgiev f234132629 [#1523] added Docs link in the footer 2023-01-08 00:25:09 +02:00
Gani Georgiev 9b880f5ab4 filter enhancements 2023-01-07 22:27:11 +02:00
Gani Georgiev d5775ff657 updated ui/dist and changelog 2023-01-02 23:06:19 +02:00
Khairul Haaziq 41ba7e12e1 [#1469] added webp to the image mime types presets 2023-01-02 23:02:14 +02:00
Gani Georgiev 5ef6b3a8be updated changelog 2022-12-31 16:53:45 +02:00
Gani Georgiev c673d9d314 updated github and gitee optional email fetch handling 2022-12-31 16:45:42 +02:00
Gani Georgiev a7aa3da67e Merge branch 'master' into yuxiang-gao-gitee-oauth 2022-12-31 15:00:19 +02:00
Gani Georgiev 0439af458f updated the GitHub provider to ignore extra emails request errors in case of unsufficient custom scopes 2022-12-31 14:58:30 +02:00
Yuxiang Gao 6f3241399c Update UI
Signed-off-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2022-12-31 18:05:49 +08:00
Yuxiang Gao 19ccc70fe5 Added gitee OAuth
Signed-off-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2022-12-31 17:57:40 +08:00
Gani Georgiev 9033cd109e updated changelog 2022-12-31 11:01:58 +02:00
Gani Georgiev e1773eead0 synced with master 2022-12-31 10:58:25 +02:00
Gani Georgiev f6fff85d07 updated changelog 2022-12-31 10:11:24 +02:00
Gani Georgiev 166d2eafc7 [#1447] fixed records listing number field value output 2022-12-31 10:08:04 +02:00
szsascha d710446c71 Implement strava oauth2 as new auth provider 2022-12-31 01:21:41 +01:00
Gani Georgiev 8f6cb5ad2d updated changelog and godoc 2022-12-29 18:58:07 +02:00
David Schissler 775417ac2b [#1420] added filesystem.NewFileFromBytes 2022-12-29 18:51:27 +02:00
Gani Georgiev 079616ee8e [#1416] fixed listing searchbar text wrapping 2022-12-28 22:27:42 +02:00
Gani Georgiev 26b794aa08 updated changelog 2022-12-24 15:54:14 +02:00
tenthree d55610511d [#1370] added IME status checking to the textarea keydown handler 2022-12-24 15:50:51 +02:00
Gani Georgiev a00606d969 updated changelog and ui/dist 2022-12-24 08:40:08 +02:00
Gani Georgiev 4abc8ae021 [#1365] fixed Record.MergeExpand panic 2022-12-24 08:34:42 +02:00
Gani Georgiev f91f009fce updated go deps 2022-12-22 16:16:21 +02:00
Gani Georgiev 7fc1d979dd added fs.UploadFile unit test and updated changelog 2022-12-22 16:06:44 +02:00
Yuxiang Gao ede7804a80 [#1343] fixed s3 file upload error caused by underscore in metadata 2022-12-22 15:39:45 +02:00
Gani Georgiev cea287a2c1 updated ui/dist 2022-12-21 19:18:57 +02:00
Joysankar Majumdar 9e03ac4dc4 [#1332] fixed request verification docs api url 2022-12-21 19:17:05 +02:00
Gani Georgiev e713de1e44 updated excluded meta props in the relation select option 2022-12-21 09:45:14 +02:00
Gani Georgiev 233ab62f8e updated js deps 2022-12-20 20:27:13 +02:00
Gani Georgiev 3449084e54 hinted indexed map size 2022-12-20 20:25:38 +02:00
466 changed files with 33114 additions and 5056 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.19.4'
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.
+334
View File
@@ -1,3 +1,337 @@
## v0.13.2
- Fixed Admin UI js error when selecting multiple `file` field as `relation` "Display fields" ([#1989](https://github.com/pocketbase/pocketbase/issues/1989)).
## v0.13.1
- Added `HEAD` request method support for the `/api/files/:collection/:recordId/:filename` route ([#1976](https://github.com/pocketbase/pocketbase/discussions/1976)).
## v0.13.0
- Added new "View" collection type allowing you to create a read-only collection from a custom SQL `SELECT` statement. It supports:
- aggregations (`COUNT()`, `MIN()`, `MAX()`, `GROUP BY`, etc.)
- column and table aliases
- CTEs and subquery expressions
- auto `relation` fields association
- `file` fields proxying (up to 5 linked relations, eg. view1->view2->...->base)
- `filter`, `sort` and `expand`
- List and View API rules
- Added auto fail/retry (default to 8 attempts) for the `SELECT` queries to gracefully handle the `database is locked` errors ([#1795](https://github.com/pocketbase/pocketbase/discussions/1795#discussioncomment-4882169)).
_The default max attempts can be accessed or changed via `Dao.MaxLockRetries`._
- Added default max query execution timeout (30s).
_The default timeout can be accessed or changed via `Dao.ModelQueryTimeout`._
_For the prebuilt executables it can be also changed via the `--queryTimeout=10` flag._
- Added support for `dao.RecordQuery(collection)` to scan directly the `One()` and `All()` results in `*models.Record` or `[]*models.Record` without the need of explicit `NullStringMap`.
- Added support to overwrite the default file serve headers if an explicit response header is set.
- Added file thumbs when visualizing `relation` display file fields.
- Added "Min select" `relation` field option.
- Enabled `process.env` in JS migrations to allow accessing `os.Environ()`.
- Added `UploadedFiles` field to the `RecordCreateEvent` and `RecordUpdateEvent` event structs.
- **!** Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
- **!** Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
- **!** Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
If you are sending custom emails, you'll have to replace:
```go
message := &mailer.Message{
...
// (old) To: mail.Address{Address: "to@example.com"}
To: []mail.Address{{Address: "to@example.com", Name: "Some optional name"}},
// (old) Cc: []string{"cc@example.com"}
Cc: []mail.Address{{Address: "cc@example.com", Name: "Some optional name"}},
// (old) Bcc: []string{"bcc@example.com"}
Bcc: []mail.Address{{Address: "bcc@example.com", Name: "Some optional name"}},
...
}
```
- **!** Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
_If you've previously used Authentik, make sure to rename the provider key in your code to `oidc`._
_To enable more than one OIDC provider you can use the additional `oidc2` and `oidc3` provider keys._
- **!** Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
- Updated the internal redirects to allow easier subpath deployment when behind a reverse proxy.
- Other minor Admin UI improvements.
## 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)).
## v0.11.0
- Added `+` and `-` body field modifiers for `number`, `files`, `select` and `relation` fields.
```js
{
// oldValue + 2
"someNumber+": 2,
// oldValue + ["id1", "id2"] - ["id3"]
"someRelation+": ["id1", "id2"],
"someRelation-": ["id3"],
// delete single file by its name (file fields supports only the "-" modifier!)
"someFile-": "filename.png",
}
```
_Note1: `@request.data.someField` will contain the final resolved value._
_Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
- ! Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
If you want a "at least one of" type of condition, you can prefix the operator with `?`.
```js
// for each someRelA.someRelB record require the "status" field to be "active"
someRelA.someRelB.status = "active"
// OR for "at least one of" condition
someRelA.someRelB.status ?= "active"
```
_**Note: Previously the behavior for multi-valued fields was as the "at least one of" type.
The release comes with system db migration that will update your existing API rules (if needed) to preserve the compatibility.
If you have multi-select or multi-relation filter checks in your client-side code and want to preserve the old behavior, you'll have to prefix with `?` your operators.**_
- Added support for querying `@request.data.someRelField.*` relation fields.
```js
// example submitted data: {"someRel": "REL_RECORD_ID"}
@request.data.someRel.status = "active"
```
- Added `:isset` modifier for the static request data fields.
```js
// prevent changing the "role" field
@request.data.role:isset = false
```
- Added `:length` modifier for the arrayable request data and collection fields (`select`, `file`, `relation`).
```js
// example submitted data: {"someSelectField": ["val1", "val2"]}
@request.data.someSelectField:length = 2
// check existing record field length
someSelectField:length = 2
```
- Added `:each` modifier support for the multi-`select` request data and collection field.
```js
// check if all selected rows has "pb_" prefix
roles:each ~ 'pb_%'
```
- Improved the Admin UI filters autocomplete.
- Added `@random` sort key for `RANDOM()` sorted list results.
- Added Strava OAuth2 provider ([#1443](https://github.com/pocketbase/pocketbase/pull/1443); thanks @szsascha).
- Added Gitee OAuth2 provider ([#1448](https://github.com/pocketbase/pocketbase/pull/1448); thanks @yuxiang-gao).
- Added IME status check to the textarea keydown handler ([#1370](https://github.com/pocketbase/pocketbase/pull/1370); thanks @tenthree).
- Added `filesystem.NewFileFromBytes()` helper ([#1420](https://github.com/pocketbase/pocketbase/pull/1420); thanks @dschissler).
- Added support for reordering uploaded multiple files.
- Added `webp` to the default images mime type presets list ([#1469](https://github.com/pocketbase/pocketbase/pull/1469); thanks @khairulhaaziq).
- Added the OAuth2 refresh token to the auth meta response ([#1487](https://github.com/pocketbase/pocketbase/issues/1487)).
- Fixed the text wrapping in the Admin UI listing searchbar ([#1416](https://github.com/pocketbase/pocketbase/issues/1416)).
- Fixed number field value output in the records listing ([#1447](https://github.com/pocketbase/pocketbase/issues/1447)).
- Fixed duplicated settings view pages caused by uncompleted transitions ([#1498](https://github.com/pocketbase/pocketbase/issues/1498)).
- Allowed sending `Authorization` header with the `/auth-with-password` record and admin login requests ([#1494](https://github.com/pocketbase/pocketbase/discussions/1494)).
- `migrate down` now reverts migrations in the applied order.
- Added additional list-bucket check in the S3 config test API.
- Other minor improvements.
## v0.10.4
- Fixed `Record.MergeExpand` panic when the main model expand map is not initialized ([#1365](https://github.com/pocketbase/pocketbase/issues/1365)).
## v0.10.3
- ! Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
- Excluded `collectionId` and `collectionName` from the displayable relation props list ([1322](https://github.com/pocketbase/pocketbase/issues/1322); thanks @dhall2).
## v0.10.2
- Fixed nested multiple expands with shared path ([#586](https://github.com/pocketbase/pocketbase/issues/586#issuecomment-1357784227)).
+1 -1
View File
@@ -1,5 +1,5 @@
The MIT License (MIT)
Copyright (c) 2022, Gani Georgiev
Copyright (c) 2022 - present, Gani Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
+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
+134 -53
View File
@@ -18,7 +18,7 @@ func bindAdminApi(app core.App, rg *echo.Group) {
api := adminApi{app: app}
subGroup := rg.Group("/admins", ActivityLogger(app))
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/auth-with-password", api.authWithPassword)
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
@@ -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
+41 -19
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,17 +50,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid email/password (already authorized)",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
},
{
Name: "valid email/password (guest)",
@@ -70,7 +65,28 @@ func TestAdminAuthWithEmail(t *testing.T) {
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1,
"OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
"OnAdminAuthRequest": 1,
},
},
{
Name: "valid email/password (already authorized)",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
"OnAdminAuthRequest": 1,
},
},
}
@@ -114,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,
},
},
{
@@ -200,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,
},
},
}
@@ -253,7 +273,9 @@ func TestAdminRefresh(t *testing.T) {
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1,
"OnAdminAuthRequest": 1,
"OnAdminBeforeAuthRefreshRequest": 1,
"OnAdminAfterAuthRefreshRequest": 1,
},
},
}
+14 -8
View File
@@ -26,6 +26,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New()
e.Debug = app.IsDebug()
// configure a custom router
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
return echo.NewRouter(echo.RouterConfig{
UnescapePathParamValues: true,
})
})
// default middlewares
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
Skipper: func(c echo.Context) bool {
@@ -64,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 {
@@ -157,7 +163,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
e.GET(
strings.TrimRight(trailedAdminPath, "/"),
func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath)
return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
},
)
@@ -218,12 +224,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
if totalAdmins == 0 && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#")
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/")
// clear the installer param
return c.Redirect(http.StatusTemporaryRedirect, "?")
}
return next(c)
+16
View File
@@ -66,6 +66,22 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "custom route with url encoded parameter",
Method: http.MethodGet,
Url: "/a%2Bb%2Bc",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/:param",
Handler: func(c echo.Context) error {
return c.String(200, c.PathParam("param"))
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"a+b+c"},
},
{
Name: "route with HTTPError",
Method: http.MethodGet,
+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
+228 -17
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) {
@@ -42,7 +45,7 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":7`,
`"totalItems":10`,
`"items":[{`,
`"id":"_pb_users_auth_"`,
`"id":"v851q4r790rhknl"`,
@@ -51,6 +54,7 @@ func TestCollectionsList(t *testing.T) {
`"id":"sz5l5z67tg7gku0"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"9n89pl5vkct6330"`,
`"type":"auth"`,
`"type":"base"`,
},
@@ -69,10 +73,10 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
`"totalItems":7`,
`"totalItems":10`,
`"items":[{`,
`"id":"4d1blo5cuycfaca"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"kpv709sk2lqbqk8"`,
`"id":"9n89pl5vkct6330"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -99,12 +103,13 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":4`,
`"totalItems":5`,
`"items":[{`,
`"id":"wsmn24bux7wo113"`,
`"id":"sz5l5z67tg7gku0"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"9n89pl5vkct6330"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -226,10 +231,11 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodDelete,
Url: "/api/collections/demo1",
Url: "/api/collections/demo5",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
@@ -238,16 +244,17 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
Name: "authorized as admin + using the collection id",
Method: http.MethodDelete,
Url: "/api/collections/wsmn24bux7wo113",
Url: "/api/collections/9n89pl5vkct6330",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
@@ -256,7 +263,7 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
@@ -285,6 +292,22 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
},
},
{
Name: "authorized as admin + deleting a view",
Method: http.MethodDelete,
Url: "/api/collections/view2",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -513,6 +536,56 @@ func TestCollectionCreate(t *testing.T) {
`"options":{"minPasswordLength":{"code":"validation_required"`,
},
},
// view
// -----------------------------------------------------------
{
Name: "trying to create view collection with invalid options",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options":{"query": "invalid"}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"options":{"query":{"code":"validation_invalid_view_query`,
},
},
{
Name: "creating view collection",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options": {
"query": "select 1 as id from _admins"
}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"view"`,
`"schema":[]`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -653,7 +726,7 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "updating base collection with reserved auth fields",
Method: http.MethodPatch,
Url: "/api/collections/demo1",
Url: "/api/collections/demo4",
Body: strings.NewReader(`{
"schema":[
{"type":"text","name":"email"},
@@ -674,7 +747,7 @@ func TestCollectionUpdate(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"demo1"`,
`"name":"demo4"`,
`"type":"base"`,
`"schema":[{`,
`"email"`,
@@ -742,6 +815,142 @@ 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)
}
},
},
// view
// -----------------------------------------------------------
{
Name: "trying to update view collection with invalid options",
Method: http.MethodPatch,
Url: "/api/collections/view1",
Body: strings.NewReader(`{
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options":{"query": "invalid"}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"options":{"query":{"code":"validation_invalid_view_query`,
},
},
{
Name: "updating view collection",
Method: http.MethodPatch,
Url: "/api/collections/view2",
Body: strings.NewReader(`{
"name":"view2_update",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options": {
"query": "select 2 as id, created, updated, email from _admins"
}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"view2_update"`,
`"type":"view"`,
`"schema":[{`,
`"name":"email"`,
},
NotExpectedContent: []string{
// base model fields are not part of the schema
`"name":"id"`,
`"name":"created"`,
`"name":"updated"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -750,6 +959,8 @@ func TestCollectionUpdate(t *testing.T) {
}
func TestCollectionImport(t *testing.T) {
totalCollections := 10
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -786,7 +997,7 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 7
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -814,7 +1025,7 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 7
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -856,7 +1067,7 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 7
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -909,7 +1120,7 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 10
expected := totalCollections + 3
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -996,8 +1207,8 @@ func TestCollectionImport(t *testing.T) {
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 5,
"OnModelAfterDelete": 5,
"OnModelBeforeDelete": 8,
"OnModelAfterDelete": 8,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
+29 -10
View File
@@ -1,6 +1,8 @@
package apis
import (
"fmt"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
@@ -16,6 +18,7 @@ func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app}
subGroup := rg.Group("/files", ActivityLogger(app))
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
}
@@ -45,15 +48,27 @@ func (api *fileApi) download(c echo.Context) error {
if fileField == nil {
return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
baseFilesPath := record.BaseFilesPath()
// fetch the original view file field related record
if collection.IsView() {
fileRecord, err := api.app.Dao().FindRecordByViewFile(collection.Id, fileField.Name, filename)
if err != nil {
return NewNotFoundError("", fmt.Errorf("Failed to fetch view file field record: %w", err))
}
baseFilesPath = fileRecord.BaseFilesPath()
}
fs, err := api.app.NewFilesystem()
if err != nil {
return NewBadRequestError("Filesystem initialization failure.", err)
}
defer fs.Close()
originalPath := record.BaseFilesPath() + "/" + filename
originalPath := baseFilesPath + "/" + filename
servedPath := originalPath
servedName := filename
@@ -70,7 +85,7 @@ func (api *fileApi) download(c echo.Context) error {
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
// add thumb size as file suffix
servedName = thumbSize + "_" + filename
servedPath = record.BaseFilesPath() + "/thumbs_" + filename + "/" + servedName
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
// check if the thumb exists:
// - if doesn't exist - create a new thumb with the specified thumb size
@@ -84,14 +99,18 @@ 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
// clickjacking shouldn't be a concern when serving uploaded files,
// so it safe to unset the global X-Frame-Options to allow files embedding
// (note: it is out of the hook to allow users to customize the behavior)
c.Response().Header().Del("X-Frame-Options")
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
res := e.HttpContext.Response()
+8
View File
@@ -179,6 +179,14 @@ func TestFileDownload(t *testing.T) {
}
for _, scenario := range scenarios {
// clone for the HEAD test (the same as the original scenario but without body)
head := scenario
head.Method = http.MethodHead
head.Name = ("(HEAD) " + scenario.Name)
head.ExpectedContent = nil
head.Test(t)
// regular request test
scenario.Test(t)
}
}
+6 -4
View File
@@ -59,9 +59,12 @@ func RequireGuestOnly() echo.MiddlewareFunc {
// specifying their names.
//
// Example:
// apis.RequireRecordAuth()
//
// apis.RequireRecordAuth()
//
// Or:
// apis.RequireRecordAuth("users", "supervisors")
//
// apis.RequireRecordAuth("users", "supervisors")
//
// To restrict the auth record only to the loaded context collection,
// use [apis.RequireSameContextRecordAuth()] instead.
@@ -83,7 +86,6 @@ func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
}
}
//
// RequireSameContextRecordAuth middleware requires a request to have
// a valid record Authorization header.
//
@@ -261,7 +263,7 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
}
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
return NewBadRequestError("Invalid collection type.", nil)
return NewBadRequestError("Unsupported collection type.", nil)
}
c.Set(ContextCollectionKey, collection)
+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
}
})
}
}
+130 -100
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"
@@ -35,8 +33,8 @@ func bindRecordAuthApi(app core.App, rg *echo.Group) {
subGroup.GET("/auth-methods", api.authMethods)
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2) // allow anyone so that we can link the OAuth2 profile with the authenticated record
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
subGroup.POST("/auth-with-password", api.authWithPassword)
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/request-verification", api.requestVerification)
@@ -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 {
+77 -23
View File
@@ -66,26 +66,6 @@ func TestRecordAuthMethodsList(t *testing.T) {
func TestRecordAuthWithPassword(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "authenticated record",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authenticated admin",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "invalid body format",
Method: http.MethodPost,
@@ -120,6 +100,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid username and invalid password",
@@ -133,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",
@@ -146,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",
@@ -163,7 +152,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test2@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
@@ -180,6 +171,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
},
},
{
Name: "valid email and invalid password",
@@ -193,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",
@@ -206,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",
@@ -223,7 +223,59 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
// with already authenticated record or admin
{
Name: "authenticated record",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
Body: strings.NewReader(`{
"identity":"test@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
{
Name: "authenticated admin",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Body: strings.NewReader(`{
"identity":"test@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
}
@@ -294,7 +346,9 @@ func TestRecordAuthRefresh(t *testing.T) {
`"missing":`,
},
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeAuthRefreshRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordAfterAuthRefreshRequest": 1,
},
},
}
+74 -50
View File
@@ -26,14 +26,13 @@ func bindRecordCrudApi(app core.App, rg *echo.Group) {
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app),
)
subGroup.GET("/records", api.list)
subGroup.POST("/records", api.create)
subGroup.GET("/records/:id", api.view)
subGroup.PATCH("/records/:id", api.update)
subGroup.DELETE("/records/:id", api.delete)
subGroup.GET("/records", api.list, LoadCollectionContext(app))
subGroup.GET("/records/:id", api.view, LoadCollectionContext(app))
subGroup.POST("/records", api.create, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.PATCH("/records/:id", api.update, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.DELETE("/records/:id", api.delete, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
}
type recordApi struct {
@@ -73,22 +72,18 @@ func (api *recordApi) list(c echo.Context) error {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
records := []*models.Record{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
if err != nil {
return NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
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 +130,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() {
@@ -166,6 +161,20 @@ func (api *recordApi) create(c echo.Context) error {
// temporary save the record and check it against the create rule
if requestData.Admin == nil && collection.CreateRule != nil {
testRecord := models.NewRecord(collection)
// replace modifiers fields so that the resolved value is always
// available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
}
testForm := forms.NewRecordUpsert(api.app, testRecord)
testForm.SetFullManageAccess(true)
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
createRuleFunc := func(q *dbx.SelectQuery) error {
if *collection.CreateRule == "" {
return nil // no create rule to resolve
@@ -181,13 +190,6 @@ func (api *recordApi) create(c echo.Context) error {
return nil
}
testRecord := models.NewRecord(collection)
testForm := forms.NewRecordUpsert(api.app, testRecord)
testForm.SetFullManageAccess(true)
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
if err != nil {
@@ -211,16 +213,19 @@ 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
event.UploadedFiles = form.FilesToUpload()
// 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)
}
@@ -234,7 +239,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
@@ -258,6 +265,16 @@ func (api *recordApi) update(c echo.Context) error {
return NewForbiddenError("Only admins can perform this action.", nil)
}
// eager fetch the record so that the modifier field values are replaced
// and available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
requestData.Data = record.ReplaceModifers(requestData.Data)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
@@ -285,16 +302,19 @@ 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
event.UploadedFiles = form.FilesToUpload()
// 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)
}
@@ -308,7 +328,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
@@ -350,10 +372,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
@@ -365,7 +387,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
+287 -3
View File
@@ -2,10 +2,12 @@ package apis_test
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/models"
@@ -209,8 +211,52 @@ func TestRecordCrudList(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: ":rule modifer",
Method: http.MethodGet,
Url: "/api/collections/demo5/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":1`,
`"items":[{`,
`"id":"qjeql998mtp1azp"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "multi-match - at least one of",
Method: http.MethodGet,
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":1`,
`"items":[{`,
`"id":"qzaqccwrmva4o1n"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "multi-match - all",
Method: http.MethodGet,
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":0`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
// auth collection checks
// auth collection
// -----------------------------------------------------------
{
Name: "check email visibility as guest",
@@ -357,6 +403,63 @@ func TestRecordCrudList(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
// view collection
// -----------------------------------------------------------
{
Name: "public view records",
Method: http.MethodGet,
Url: "/api/collections/view2/records?filter=state=false",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":2`,
`"items":[{`,
`"id":"al1h9ijdeojtsjy"`,
`"id":"imy661ixudk5izi"`,
},
NotExpectedContent: []string{
`"created"`,
`"updated"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "guest that doesn't match the view collection list rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":0`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "authenticated record that matches the view collection list rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records",
RequestHeaders: map[string]string{
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":1`,
`"items":[{`,
`"id":"84nmscqy84lsi1t"`,
`"bool":true`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -485,7 +588,7 @@ func TestRecordCrudView(t *testing.T) {
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
// auth collection checks
// auth collection
// -----------------------------------------------------------
{
Name: "check email visibility as guest",
@@ -583,6 +686,49 @@ func TestRecordCrudView(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
// view collection
// -----------------------------------------------------------
{
Name: "public view record",
Method: http.MethodGet,
Url: "/api/collections/view2/records/84nmscqy84lsi1t",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"84nmscqy84lsi1t"`,
`"state":true`,
`"file_many":["`,
`"rel_many":["`,
},
NotExpectedContent: []string{
`"created"`,
`"updated"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "guest that doesn't match the view collection view rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authenticated record that matches the view collection view rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
RequestHeaders: map[string]string{
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"84nmscqy84lsi1t"`,
`"bool":true`,
`"text":"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -644,6 +790,13 @@ func TestRecordCrudDelete(t *testing.T) {
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to delete a view collection record",
Method: http.MethodDelete,
Url: "/api/collections/view1/records/imy661ixudk5izi",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection record delete",
Method: http.MethodDelete,
@@ -691,6 +844,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
@@ -716,6 +870,25 @@ func TestRecordCrudDelete(t *testing.T) {
}
},
},
{
Name: "@request :isset (rule failure check)",
Method: http.MethodDelete,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "@request :isset (rule pass check)",
Method: http.MethodDelete,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
},
// cascade delete checks
// -----------------------------------------------------------
@@ -730,7 +903,7 @@ func TestRecordCrudDelete(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnModelBeforeUpdate": 1, // self_rel_many update of test1 record
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
},
},
@@ -758,6 +931,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,
@@ -818,6 +992,14 @@ func TestRecordCrudCreate(t *testing.T) {
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to create a new view collection record",
Method: http.MethodPost,
Url: "/api/collections/view1/records",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPost,
@@ -1092,6 +1274,63 @@ func TestRecordCrudCreate(t *testing.T) {
},
},
// fields modifier checks
// -----------------------------------------------------------
{
Name: "trying to delete a record while being part of a non-cascade required relation",
Method: http.MethodDelete,
Url: "/api/collections/demo3/records/7nwo8tuiatetxdm",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
},
},
// check whether if @request.data modifer fields are properly resolved
// -----------------------------------------------------------
{
Name: "@request.data.field with compute modifers (rule failure check)",
Method: http.MethodPost,
Url: "/api/collections/demo5/records",
Body: strings.NewReader(`{
"total":1,
"total+":4,
"total-":1
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
},
{
Name: "@request.data.field with compute modifers (rule pass check)",
Method: http.MethodPost,
Url: "/api/collections/demo5/records",
Body: strings.NewReader(`{
"total":1,
"total+":3,
"total-":1
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"`,
`"collectionName":"demo5"`,
`"total":3`,
},
ExpectedEvents: map[string]int{
"OnModelAfterCreate": 1,
"OnModelBeforeCreate": 1,
"OnRecordAfterCreateRequest": 1,
"OnRecordBeforeCreateRequest": 1,
},
},
// auth records
// -----------------------------------------------------------
{
@@ -1304,6 +1543,14 @@ func TestRecordCrudUpdate(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to update a view collection record",
Method: http.MethodPatch,
Url: "/api/collections/view1/records/imy661ixudk5izi",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPatch,
@@ -1501,6 +1748,43 @@ func TestRecordCrudUpdate(t *testing.T) {
},
},
// check whether if @request.data modifer fields are properly resolved
// -----------------------------------------------------------
{
Name: "@request.data.field with compute modifers (rule failure check)",
Method: http.MethodPatch,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
Body: strings.NewReader(`{
"total+":3,
"total-":1
}`),
ExpectedStatus: 404,
ExpectedContent: []string{
`"data":{}`,
},
},
{
Name: "@request.data.field with compute modifers (rule pass check)",
Method: http.MethodPatch,
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
Body: strings.NewReader(`{
"total+":2,
"total-":1
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"la4y2w4o98acwuj"`,
`"collectionName":"demo5"`,
`"total":3`,
},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
},
// auth records
// -----------------------------------------------------------
{
+57 -11
View File
@@ -2,24 +2,23 @@ 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"
)
const ContextRequestDataKey = "requestData"
// Deprecated: Will be removed after v0.9. Use apis.RequestData(c) instead.
func GetRequestData(c echo.Context) *models.RequestData {
return RequestData(c)
}
// RequestData exports cached common request data fields
// (query, body, logged auth state, etc.) from the provided context.
func RequestData(c echo.Context) *models.RequestData {
@@ -46,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)
+23 -16
View File
@@ -1,12 +1,15 @@
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"
)
@@ -31,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)
@@ -49,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)
}
@@ -74,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
@@ -91,14 +95,17 @@ func (api *settingsApi) testS3(c echo.Context) error {
}
defer fs.Close()
testFileKey := "pb_test_" + security.PseudorandomString(5) + "/test.txt"
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
testFileKey := testPrefix + "/test.txt"
// try to upload a test file
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
if err := fs.Delete(testFileKey); err != nil {
return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
// test prefix deletion (ensures that both bucket list and delete works)
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
}
return c.NoContent(http.StatusNoContent)
+39 -6
View File
@@ -58,6 +58,13 @@ func TestSettingsList(t *testing.T) {
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -125,6 +132,13 @@ func TestSettingsSet(t *testing.T) {
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -181,6 +195,13 @@ func TestSettingsSet(t *testing.T) {
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -303,8 +324,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[verification] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
@@ -334,8 +359,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[password-reset] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
@@ -365,8 +394,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[email-change] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
+16 -5
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
@@ -78,7 +79,8 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
ReadTimeout: 60 * time.Second,
ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Addr: mainAddr,
@@ -89,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
+286 -38
View File
@@ -18,7 +18,7 @@ import (
type App interface {
// Deprecated:
// This method may get removed in the near future.
// It is recommended to access the logs db instance from app.Dao().DB() or
// It is recommended to access the app db instance from app.Dao().DB() or
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
//
// DB returns the default app database instance.
@@ -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
+195 -101
View File
@@ -17,12 +17,13 @@ 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"
)
const (
DefaultDataMaxOpenConns int = 100
DefaultDataMaxOpenConns int = 120
DefaultDataMaxIdleConns int = 20
DefaultLogsMaxOpenConns int = 10
DefaultLogsMaxIdleConns int = 2
@@ -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]{},
@@ -443,7 +472,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
// NB! Make sure to call `Close()` on the returned result
// after you are done working with it.
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
if app.settings.S3.Enabled {
if app.settings != nil && app.settings.S3.Enabled {
return filesystem.NewS3(
app.settings.S3.Bucket,
app.settings.S3.Region,
@@ -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()
+131 -10
View File
@@ -5,6 +5,9 @@ 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/filesystem"
"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 +15,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 +59,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 +144,39 @@ 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 {
HttpContext echo.Context
Record *models.Record
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
}
type RecordUpdateEvent struct {
HttpContext echo.Context
Record *models.Record
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
}
type RecordDeleteEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
@@ -134,49 +186,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 +312,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 +345,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 +378,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)
}
}
}
}
+2 -2
View File
@@ -97,8 +97,8 @@ func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
AndWhere(dbx.HashExp{"email": email}).
Limit(1)
if len(excludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
+38 -89
View File
@@ -4,19 +4,13 @@
package daos
import (
"context"
"errors"
"strings"
"sync"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"golang.org/x/sync/semaphore"
)
const DefaultMaxFailRetries = 5
// New creates a new Dao instance with the provided db builder
// (for both async and sync db operations).
func New(db dbx.Builder) *Dao {
@@ -27,8 +21,10 @@ func New(db dbx.Builder) *Dao {
// async and sync db builders.
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
return &Dao{
concurrentDB: concurrentDB,
nonconcurrentDB: nonconcurrentDB,
concurrentDB: concurrentDB,
nonconcurrentDB: nonconcurrentDB,
MaxLockRetries: 8,
ModelQueryTimeout: 30 * time.Second,
}
}
@@ -39,9 +35,13 @@ type Dao struct {
concurrentDB dbx.Builder
nonconcurrentDB dbx.Builder
// @todo delete after removing Block and Continue
sem *semaphore.Weighted
mux sync.RWMutex
// MaxLockRetries specifies the default max "database is locked" auto retry attempts.
MaxLockRetries int
// ModelQueryTimeout is the default max duration of a running ModelQuery().
//
// This field has no effect if an explicit query context is already specified.
ModelQueryTimeout time.Duration
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
AfterCreateFunc func(eventDao *Dao, m models.Model)
@@ -74,55 +74,17 @@ func (dao *Dao) NonconcurrentDB() dbx.Builder {
return dao.nonconcurrentDB
}
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
//
// Block acquires a lock and blocks all other go routines that uses
// the Dao instance until dao.Continue() is called, effectively making
// the concurrent requests to perform synchronous db operations.
//
// This method should be used only as a last resort and as a workaround
// for the SQLITE_BUSY error when mixing read&write in a transaction.
//
// Example:
//
// func someLongRunningTransaction() error {
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer cancel()
// if err := app.Dao().Block(ctx); err != nil {
// return err
// }
// defer app.Dao().Continue()
//
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
// // some long running read&write transaction...
// })
// }
func (dao *Dao) Block(ctx context.Context) error {
if dao.sem == nil {
dao.mux.Lock()
dao.sem = semaphore.NewWeighted(1)
dao.mux.Unlock()
}
return dao.sem.Acquire(ctx, 1)
}
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
//
// Continue releases the previously acquired Block() lock.
func (dao *Dao) Continue() {
if dao.sem == nil {
return
}
dao.sem.Release(1)
}
// ModelQuery creates a new query with preset Select and From fields
// based on the provided model argument.
// ModelQuery creates a new preconfigured select query with preset
// SELECT, FROM and other common fields based on the provided model.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
tableName := m.TableName()
return dao.DB().Select("{{" + tableName + "}}.*").From(tableName)
return dao.DB().
Select("{{" + tableName + "}}.*").
From(tableName).
WithBuildHook(func(query *dbx.Query) {
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries))
})
}
// FindById finds a single db record with the specified id and
@@ -224,7 +186,7 @@ func (dao *Dao) Delete(m models.Model) error {
return errors.New("ID is not set")
}
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
if retryDao.BeforeDeleteFunc != nil {
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
return err
@@ -240,20 +202,20 @@ func (dao *Dao) Delete(m models.Model) error {
}
return nil
}, DefaultMaxFailRetries)
})
}
// Save upserts (update or create if primary key is not set) the provided model.
func (dao *Dao) Save(m models.Model) error {
if m.IsNew() {
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.create(m)
}, DefaultMaxFailRetries)
})
}
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.update(m)
}, DefaultMaxFailRetries)
})
}
func (dao *Dao) update(m models.Model) error {
@@ -347,32 +309,19 @@ func (dao *Dao) create(m models.Model) error {
return nil
}
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
retryDao := dao
attempts := 1
Retry:
if attempts == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
retryDao.AfterCreateFunc = dao.AfterCreateFunc
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
}
return baseLockRetry(func(attempt int) error {
if attempt == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
retryDao.AfterCreateFunc = dao.AfterCreateFunc
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
}
// execute
err := op(retryDao)
if err != nil &&
attempts < maxRetries &&
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
attempts++
goto Retry
}
return err
return op(retryDao)
}, dao.MaxLockRetries)
}
+57
View File
@@ -0,0 +1,57 @@
package daos
import (
"context"
"strings"
"time"
"github.com/pocketbase/dbx"
)
// default retries intervals (in ms)
var defaultRetryIntervals = []int{100, 250, 350, 500, 700, 1000}
func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
return func(q *dbx.Query, op func() error) error {
if q.Context() == nil {
cancelCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
cancel()
//nolint:staticcheck
q.WithContext(nil) // reset
}()
q.WithContext(cancelCtx)
}
return baseLockRetry(func(attempt int) error {
return op()
}, maxRetries)
}
}
func baseLockRetry(op func(attempt int) error, maxRetries int) error {
attempt := 1
Retry:
err := op(attempt)
if err != nil &&
attempt <= maxRetries &&
// we are checking the err message to handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(getDefaultRetryInterval(attempt))
attempt++
goto Retry
}
return err
}
func getDefaultRetryInterval(attempt int) time.Duration {
if attempt < 0 || attempt > len(defaultRetryIntervals)-1 {
return time.Duration(defaultRetryIntervals[len(defaultRetryIntervals)-1]) * time.Millisecond
}
return time.Duration(defaultRetryIntervals[attempt]) * time.Millisecond
}
+60
View File
@@ -0,0 +1,60 @@
package daos
import (
"errors"
"testing"
)
func TestGetDefaultRetryInterval(t *testing.T) {
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i)
}
if i := getDefaultRetryInterval(999); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i)
}
if i := getDefaultRetryInterval(3); i.Milliseconds() != 500 {
t.Fatalf("Expected 500ms, got %v", i)
}
}
func TestBaseLockRetry(t *testing.T) {
scenarios := []struct {
err error
failUntilAttempt int
expectedAttempts int
}{
{nil, 3, 1},
{errors.New("test"), 3, 1},
{errors.New("database is locked"), 3, 3},
}
for i, s := range scenarios {
lastAttempt := 0
err := baseLockRetry(func(attempt int) error {
lastAttempt = attempt
if attempt < s.failUntilAttempt {
return s.err
}
return nil
}, s.failUntilAttempt+2)
if lastAttempt != s.expectedAttempts {
t.Errorf("[%d] Expected lastAttempt to be %d, got %d", i, s.expectedAttempts, lastAttempt)
}
if s.failUntilAttempt == s.expectedAttempts && err != nil {
t.Errorf("[%d] Expected nil, got err %v", i, err)
continue
}
if s.failUntilAttempt != s.expectedAttempts && s.err != nil && err == nil {
t.Errorf("[%d] Expected error %q, got nil", i, s.err)
continue
}
}
}
+19
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"errors"
"testing"
"time"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
@@ -71,6 +72,24 @@ func TestDaoModelQuery(t *testing.T) {
}
}
func TestDaoModelQueryCancellation(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
dao := daos.New(testApp.DB())
m := &models.Admin{}
if err := dao.ModelQuery(m).One(m); err != nil {
t.Fatalf("Failed to execute control query: %v", err)
}
dao.ModelQueryTimeout = 0 * time.Millisecond
if err := dao.ModelQuery(m).One(m); err == nil {
t.Fatal("Expected to be cancelled, got nil")
}
}
func TestDaoFindById(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+87 -18
View File
@@ -65,8 +65,7 @@ func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1)
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
@@ -85,15 +84,17 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
collections := []*models.Collection{}
query := dao.CollectionQuery()
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
if err := query.All(&collections); err != nil {
return nil, err
}
result := map[*models.Collection][]*schema.SchemaField{}
for _, c := range collections {
for _, f := range c.Schema.Fields() {
if f.Type != schema.FieldTypeRelation {
@@ -128,13 +129,23 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
return err
}
if total := len(result); total > 0 {
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total)
names := make([]string, 0, len(result))
for ref := range result {
names = append(names, ref.Name)
}
return fmt.Errorf("The collection %q has external relation field references (%s).", collection.Name, strings.Join(names, ", "))
}
return dao.RunInTransaction(func(txDao *Dao) error {
// delete the related records table
if err := txDao.DeleteTable(collection.Name); err != nil {
return err
// delete the related view or records table
if collection.IsView() {
if err := txDao.DeleteView(collection.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(collection.Name); err != nil {
return err
}
}
return txDao.Delete(collection)
@@ -162,13 +173,18 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
collection.Type = models.CollectionTypeBase
}
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
}
switch collection.Type {
case models.CollectionTypeView:
return txDao.saveViewCollection(collection, oldCollection)
default:
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
}
// sync the changes with the related records table
return txDao.SyncRecordTableSchema(collection, oldCollection)
// sync the changes with the related records table
return txDao.SyncRecordTableSchema(collection, oldCollection)
}
})
}
@@ -273,8 +289,14 @@ func (dao *Dao) ImportCollections(
continue // exist
}
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
if existing.IsView() {
if err := txDao.DeleteView(existing.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
}
}
}
@@ -282,11 +304,58 @@ func (dao *Dao) ImportCollections(
// sync the upserted collections with the related records table
for _, imported := range importedCollections {
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
if imported.IsView() {
if err := txDao.saveViewCollection(imported, existing); err != nil {
return err
}
} else {
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
}
}
}
return nil
})
}
// saveViewCollection persists the provided View collection changes:
// - deletes the old related SQL view (if any)
// - creates a new SQL view with the latest newCollection.Options.Query
// - generates a new schema based on newCollection.Options.Query
// - updates newCollection.Schema based on the generated view table info and query
// - saves the newCollection
//
// This method returns an error if newCollection is not a "view".
func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollection *models.Collection) error {
if newCollection.IsAuth() {
return errors.New("not a view collection")
}
return dao.RunInTransaction(func(txDao *Dao) error {
query := newCollection.ViewOptions().Query
// generate collection schema from the query
schema, err := txDao.CreateViewSchema(query)
if err != nil {
return err
}
// delete old renamed view
if oldCollection != nil && newCollection.Name != oldCollection.Name {
if err := txDao.DeleteView(oldCollection.Name); err != nil {
return err
}
}
// (re)create the view
if err := txDao.SaveView(newCollection.Name, query); err != nil {
return err
}
newCollection.Schema = schema
return txDao.Save(newCollection)
})
}
+68 -33
View File
@@ -37,7 +37,7 @@ func TestFindCollectionsByType(t *testing.T) {
{"", false, 0},
{"unknown", false, 0},
{models.CollectionTypeAuth, false, 3},
{models.CollectionTypeBase, false, 4},
{models.CollectionTypeBase, false, 5},
}
for i, scenario := range scenarios {
@@ -45,16 +45,16 @@ func TestFindCollectionsByType(t *testing.T) {
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if len(collections) != scenario.expectTotal {
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
t.Errorf("[%d] Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
}
for _, c := range collections {
if c.Type != scenario.collectionType {
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
t.Errorf("[%d] Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
}
}
}
@@ -80,11 +80,11 @@ func TestFindCollectionByNameOrId(t *testing.T) {
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
t.Errorf("[%d] Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
}
}
}
@@ -108,7 +108,7 @@ func TestIsCollectionNameUnique(t *testing.T) {
for i, scenario := range scenarios {
result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
}
}
}
@@ -122,7 +122,13 @@ func TestFindCollectionReferences(t *testing.T) {
t.Fatal(err)
}
result, err := app.Dao().FindCollectionReferences(collection, collection.Id)
result, err := app.Dao().FindCollectionReferences(
collection,
collection.Id,
// test whether "nonempty" exclude ids condition will be skipped
"",
"",
)
if err != nil {
t.Fatal(err)
}
@@ -149,7 +155,7 @@ func TestFindCollectionReferences(t *testing.T) {
}
for i, f := range fields {
if !list.ExistInSlice(f.Name, expectedFields) {
t.Fatalf("(%d) Didn't expect field %v", i, f)
t.Fatalf("[%d] Didn't expect field %v", i, f)
}
}
}
@@ -159,21 +165,29 @@ func TestDeleteCollection(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
c0 := &models.Collection{}
c1, err := app.Dao().FindCollectionByNameOrId("clients")
colEmpty := &models.Collection{}
colAuth, err := app.Dao().FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
c2, err := app.Dao().FindCollectionByNameOrId("demo2")
colReferenced, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
colSystem, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
c3.System = true
if err := app.Dao().Save(c3); err != nil {
colSystem.System = true
if err := app.Dao().Save(colSystem); err != nil {
t.Fatal(err)
}
colView, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
@@ -181,18 +195,28 @@ func TestDeleteCollection(t *testing.T) {
model *models.Collection
expectError bool
}{
{c0, true},
{c1, false},
{c2, true}, // is part of a reference
{c3, true}, // system
{colEmpty, true},
{colAuth, false},
{colReferenced, true},
{colSystem, true},
{colView, false},
}
for i, scenario := range scenarios {
err := app.Dao().DeleteCollection(scenario.model)
hasErr := err != nil
for i, s := range scenarios {
err := app.Dao().DeleteCollection(s.model)
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if app.Dao().HasTable(s.model.Name) {
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
}
}
}
@@ -238,7 +262,7 @@ func TestSaveCollectionCreate(t *testing.T) {
}
for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c)
t.Fatalf("[%d] Didn't expect record column %s", i, c)
}
}
}
@@ -276,12 +300,14 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c)
t.Fatalf("[%d] Didn't expect record column %s", i, c)
}
}
}
func TestImportCollections(t *testing.T) {
totalCollections := 10
scenarios := []struct {
name string
jsonData string
@@ -296,7 +322,7 @@ func TestImportCollections(t *testing.T) {
name: "empty collections",
jsonData: `[]`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
},
{
name: "minimal collection import",
@@ -306,7 +332,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 9,
expectCollectionsCount: totalCollections + 2,
},
{
name: "minimal collection import + failed beforeRecordsSync",
@@ -318,7 +344,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
},
{
name: "minimal collection import + successful beforeRecordsSync",
@@ -330,7 +356,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: false,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections + 1,
},
{
name: "new + update + delete system collection",
@@ -366,7 +392,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: true,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
},
{
name: "new + update + delete non-system collection",
@@ -436,11 +462,19 @@ func TestImportCollections(t *testing.T) {
"type":"bool"
}
]
},
{
"id": "test_new_view",
"name": "new_view",
"type": "view",
"options": {
"query": "select id from demo2"
}
}
]`,
deleteMissing: true,
expectError: false,
expectCollectionsCount: 3,
expectCollectionsCount: 4,
},
{
name: "test with deleteMissing: false",
@@ -495,7 +529,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections + 1,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
"nologin": 1,
@@ -503,6 +537,7 @@ func TestImportCollections(t *testing.T) {
"demo2": 2,
"demo3": 2,
"demo4": 11,
"demo5": 6,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
+2 -2
View File
@@ -12,8 +12,8 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.ExternalAuth{})
}
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
/// linked to the provided auth record.
// FindAllExternalAuthsByRecord returns all ExternalAuth models
// linked to the provided auth record.
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
auths := []*models.ExternalAuth{}
+119 -72
View File
@@ -3,7 +3,6 @@ package daos
import (
"errors"
"fmt"
"math"
"strings"
"github.com/pocketbase/dbx"
@@ -21,7 +20,74 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
tableName := collection.Name
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
return dao.DB().Select(selectCols).From(tableName)
return dao.DB().
Select(selectCols).
From(tableName).
WithBuildHook(func(query *dbx.Query) {
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
switch v := a.(type) {
case *models.Record:
if v == nil {
return op(a)
}
row := dbx.NullStringMap{}
if err := op(&row); err != nil {
return err
}
record := models.NewRecordFromNullStringMap(collection, row)
*v = *record
return nil
default:
return op(a)
}
}).
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
switch v := sliceA.(type) {
case *[]*models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
*v = records
return nil
case *[]models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
nonPointers := make([]models.Record, len(records))
for i, r := range records {
nonPointers[i] = *r
}
*v = nonPointers
return nil
default:
return op(sliceA)
}
})
})
}
// FindRecordById finds the Record model by its id.
@@ -35,10 +101,8 @@ func (dao *Dao) FindRecordById(
return nil, err
}
tableName := collection.Name
query := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{tableName + ".id": recordId})
AndWhere(dbx.HashExp{collection.Name + ".id": recordId})
for _, filter := range optFilters {
if filter == nil {
@@ -49,12 +113,13 @@ func (dao *Dao) FindRecordById(
}
}
row := dbx.NullStringMap{}
if err := query.Limit(1).One(row); err != nil {
record := &models.Record{}
if err := query.Limit(1).One(record); err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// FindRecordsByIds finds all Record models by the provided ids.
@@ -84,12 +149,13 @@ func (dao *Dao) FindRecordsByIds(
}
}
rows := []dbx.NullStringMap{}
if err := query.All(&rows); err != nil {
records := make([]*models.Record, 0, len(recordIds))
if err := query.All(&records); err != nil {
return nil, err
}
return models.NewRecordsFromNullStringMaps(collection, rows), nil
return records, nil
}
// FindRecordsByExpr finds all records by the specified db expression.
@@ -99,6 +165,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)
@@ -117,35 +184,38 @@ func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expres
}
}
rows := []dbx.NullStringMap{}
var records []*models.Record
if err := query.All(&rows); err != nil {
if err := query.All(&records); err != nil {
return nil, err
}
return models.NewRecordsFromNullStringMaps(collection, rows), nil
return records, nil
}
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
func (dao *Dao) FindFirstRecordByData(
collectionNameOrId string,
key string,
value any,
) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
@@ -192,8 +262,7 @@ func (dao *Dao) IsRecordValueUnique(
AndWhere(expr).
Limit(1)
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
@@ -249,18 +318,17 @@ func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
@@ -275,20 +343,19 @@ func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username str
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(username),
})).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
@@ -390,31 +457,38 @@ 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 {
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
for refCollection, fields := range refs {
if refCollection.IsView() {
continue // skip view collections
}
for _, field := range fields {
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
// @todo optimize single relation lookup
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
}
@@ -424,45 +498,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
}
+115 -3
View File
@@ -36,6 +36,91 @@ func TestRecordQuery(t *testing.T) {
}
}
func TestRecordQueryOneWithRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
id := "84nmscqy84lsi1t"
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": id})
record := &models.Record{}
if err := q.One(record); err != nil {
t.Fatal(err)
}
if record.GetString("id") != id {
t.Fatalf("Expected record with id %q, got %q", id, record.GetString("id"))
}
}
func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
id1 := "84nmscqy84lsi1t"
id2 := "al1h9ijdeojtsjy"
{
records := []models.Record{}
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": []any{id1, id2}}).
OrderBy("created asc")
if err := q.All(&records); err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatalf("Expected %d records, got %d", 2, len(records))
}
if records[0].Id != id1 {
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
}
if records[1].Id != id2 {
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
}
}
{
records := []*models.Record{}
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": []any{id1, id2}}).
OrderBy("created asc")
if err := q.All(&records); err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatalf("Expected %d records, got %d", 2, len(records))
}
if records[0].Id != id1 {
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
}
if records[1].Id != id2 {
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
}
}
}
func TestFindRecordById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -663,11 +748,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 +778,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 +816,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 +872,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 +896,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
}
+34 -3
View File
@@ -1,16 +1,19 @@
package daos
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
)
// HasTable checks if a table with the provided name exists (case insensitive).
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
func (dao *Dao) HasTable(tableName string) bool {
var exists bool
err := dao.DB().Select("count(*)").
From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "table"}).
AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
Limit(1).
Row(&exists)
@@ -29,9 +32,37 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
return columns, err
}
// GetTableInfo returns the `table_info` pragma result for the specified table.
func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
info := []*models.TableInfoRow{}
err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
All(&info)
if err != nil {
return nil, err
}
// mattn/go-sqlite3 doesn't throw an error on invalid or missing table
// so we additionally have to check whether the loaded info result is nonempty
if len(info) == 0 {
return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
}
return info, nil
}
// DeleteTable drops the specified table.
//
// This method is a no-op if a table with the provided name doesn't exist.
//
// Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
func (dao *Dao) DeleteTable(tableName string) error {
_, err := dao.DB().DropTable(tableName).Execute()
_, err := dao.DB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}",
tableName,
)).Execute()
return err
}
+44 -12
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
@@ -23,12 +24,13 @@ func TestHasTable(t *testing.T) {
{"_admins", true},
{"demo3", true},
{"DEMO3", true}, // table names are case insensitives by default
{"view1", true}, // view
}
for i, scenario := range scenarios {
result := app.Dao().HasTable(scenario.tableName)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
}
}
}
@@ -45,21 +47,50 @@ func TestGetTableColumns(t *testing.T) {
{"_params", []string{"id", "key", "value", "created", "updated"}},
}
for i, scenario := range scenarios {
columns, _ := app.Dao().GetTableColumns(scenario.tableName)
for i, s := range scenarios {
columns, _ := app.Dao().GetTableColumns(s.tableName)
if len(columns) != len(scenario.expected) {
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expected, columns)
if len(columns) != len(s.expected) {
t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
continue
}
for _, c := range columns {
if !list.ExistInSlice(c, scenario.expected) {
t.Errorf("(%d) Didn't expect column %s", i, c)
if !list.ExistInSlice(c, s.expected) {
t.Errorf("[%d] Didn't expect column %s", i, c)
}
}
}
}
func TestGetTableInfo(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
tableName string
expected string
}{
{"", "null"},
{"missing", "null"},
{
"_admins",
`[{"PK":1,"Index":0,"Name":"id","Type":"TEXT","NotNull":false,"DefaultValue":null},{"PK":0,"Index":1,"Name":"avatar","Type":"INTEGER","NotNull":true,"DefaultValue":0},{"PK":0,"Index":2,"Name":"email","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":3,"Name":"tokenKey","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":4,"Name":"passwordHash","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":5,"Name":"lastResetSentAt","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":6,"Name":"created","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":7,"Name":"updated","Type":"TEXT","NotNull":true,"DefaultValue":""}]`,
},
}
for i, s := range scenarios {
rows, _ := app.Dao().GetTableInfo(s.tableName)
raw, _ := json.Marshal(rows)
rawStr := string(raw)
if rawStr != s.expected {
t.Errorf("[%d] Expected \n%v, \ngot \n%v", i, s.expected, rawStr)
}
}
}
func TestDeleteTable(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -69,16 +100,17 @@ func TestDeleteTable(t *testing.T) {
expectError bool
}{
{"", true},
{"test", true},
{"test", false}, // missing tables are ignored
{"_admins", false},
{"demo3", false},
}
for i, scenario := range scenarios {
err := app.Dao().DeleteTable(scenario.tableName)
for i, s := range scenarios {
err := app.Dao().DeleteTable(s.tableName)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
if hasErr != s.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v", i, s.expectError, hasErr)
}
}
}
+583
View File
@@ -0,0 +1,583 @@
package daos
import (
"errors"
"fmt"
"io"
"regexp"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/tokenizer"
"github.com/pocketbase/pocketbase/tools/types"
)
// DeleteView drops the specified view name.
//
// This method is a no-op if a view with the provided name doesn't exist.
//
// Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input!
func (dao *Dao) DeleteView(name string) error {
_, err := dao.DB().NewQuery(fmt.Sprintf(
"DROP VIEW IF EXISTS {{%s}}",
name,
)).Execute()
return err
}
// SaveView creates (or updates already existing) persistent SQL view.
//
// Be aware that this method is vulnerable to SQL injection and the
// "selectQuery" argument must come only from trusted input!
func (dao *Dao) SaveView(name string, selectQuery string) error {
return dao.RunInTransaction(func(txDao *Dao) error {
// delete old view (if exists)
if err := txDao.DeleteView(name); err != nil {
return err
}
trimmed := strings.Trim(selectQuery, ";")
// try to eagerly detect multiple inline statements
tk := tokenizer.NewFromString(trimmed)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
}
// (re)create the view
//
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution.
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
return err
}
// fetch the view table info to ensure that the view was created
// because missing tables or columns won't return an error
if _, err := txDao.GetTableInfo(name); err != nil {
return err
}
return nil
})
}
// CreateViewSchema creates a new view schema from the provided select query.
//
// There are some caveats:
// - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
result := schema.NewSchema()
suggestedFields, err := dao.parseQueryToFields(selectQuery)
if err != nil {
return result, err
}
// note wrap in a transaction in case the selectQuery contains
// multiple statements allowing us to rollback on any error
txErr := dao.RunInTransaction(func(txDao *Dao) error {
tempView := "_temp_" + security.PseudorandomString(5)
// create a temp view with the provided query
if err := txDao.SaveView(tempView, selectQuery); err != nil {
return err
}
defer txDao.DeleteView(tempView)
// extract the generated view table info
info, err := txDao.GetTableInfo(tempView)
if err != nil {
return err
}
var hasId bool
for _, row := range info {
if row.Name == schema.FieldNameId {
hasId = true
}
if list.ExistInSlice(row.Name, schema.BaseModelFieldNames()) {
continue // skip base model fields since they are not part of the schema
}
var field *schema.SchemaField
if f, ok := suggestedFields[row.Name]; ok {
field = f.field
} else {
field = defaultViewField(row.Name)
}
result.AddField(field)
}
if !hasId {
return errors.New("missing required id column (you ca use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
}
return nil
})
return result, txErr
}
// FindRecordByViewFile returns the original models.Record of the
// provided view collection file.
func (dao *Dao) FindRecordByViewFile(
viewCollectionNameOrId string,
fileFieldName string,
filename string,
) (*models.Record, error) {
view, err := dao.FindCollectionByNameOrId(viewCollectionNameOrId)
if err != nil {
return nil, err
}
if !view.IsView() {
return nil, errors.New("not a view collection")
}
var findFirstNonViewQueryFileField func(int) (*queryField, error)
findFirstNonViewQueryFileField = func(level int) (*queryField, error) {
// check the level depth to prevent infinite circular recursion
// (the limit is arbitrary and may change in the future)
if level > 5 {
return nil, errors.New("reached the max recursion level of view collection file field queries")
}
queryFields, err := dao.parseQueryToFields(view.ViewOptions().Query)
if err != nil {
return nil, err
}
for _, item := range queryFields {
if item.collection == nil ||
item.original == nil ||
item.field.Name != fileFieldName {
continue
}
if item.collection.IsView() {
view = item.collection
fileFieldName = item.original.Name
return findFirstNonViewQueryFileField(level + 1)
}
return item, nil
}
return nil, errors.New("no query file field found")
}
qf, err := findFirstNonViewQueryFileField(1)
if err != nil {
return nil, err
}
cleanFieldName := inflector.Columnify(qf.original.Name)
record := &models.Record{}
err = dao.RecordQuery(qf.collection).
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 {{_je_file}}`,
cleanFieldName, cleanFieldName, cleanFieldName,
), dbx.HashExp{"_je_file.value": filename}).
Limit(1).
One(record)
if err != nil {
return nil, err
}
return record, nil
}
// -------------------------------------------------------------------
// Raw query to schema helpers
// -------------------------------------------------------------------
type queryField struct {
// field is the final resolved field.
field *schema.SchemaField
// collection refers to the original field's collection model.
// It could be nil if the found query field is not from a collection schema.
collection *models.Collection
// original is the original found collection field.
// It could be nil if the found query field is not from a collection schema.
original *schema.SchemaField
}
func defaultViewField(name string) *schema.SchemaField {
return &schema.SchemaField{
Name: name,
Type: schema.FieldTypeJson,
}
}
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
p := new(identifiersParser)
if err := p.parse(selectQuery); err != nil {
return nil, err
}
collections, err := dao.findCollectionsByIdentifiers(p.tables)
if err != nil {
return nil, err
}
result := make(map[string]*queryField, len(p.columns))
var mainTable identifier
if len(p.tables) > 0 {
mainTable = p.tables[0]
}
for _, col := range p.columns {
colLower := strings.ToLower(col.original)
// numeric expression cast
if strings.Contains(colLower, "(") &&
(strings.HasPrefix(colLower, "count(") ||
strings.HasPrefix(colLower, "total(") ||
strings.Contains(colLower, " as numeric") ||
strings.Contains(colLower, " as real") ||
strings.Contains(colLower, " as int") ||
strings.Contains(colLower, " as integer") ||
strings.Contains(colLower, " as decimal")) {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeNumber,
},
}
continue
}
parts := strings.Split(col.original, ".")
var fieldName string
var collection *models.Collection
var isMainTableField bool
if len(parts) == 2 {
fieldName = parts[1]
collection = collections[parts[0]]
isMainTableField = parts[0] == mainTable.alias
} else {
fieldName = parts[0]
collection = collections[mainTable.alias]
isMainTableField = true
}
// fallback to the default field if the found column is not from a collection schema
if collection == nil {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
}
continue
}
if fieldName == "*" {
return nil, errors.New("dynamic column names are not supported")
}
// find the first field by name (case insensitive)
var field *schema.SchemaField
for _, f := range collection.Schema.Fields() {
if strings.EqualFold(f.Name, fieldName) {
field = f
break
}
}
if field != nil {
clone := *field
clone.Name = col.alias
result[col.alias] = &queryField{
field: &clone,
collection: collection,
original: field,
}
continue
}
if fieldName == schema.FieldNameId && !isMainTableField {
// convert to relation since it is a direct id reference to non-maintable collection
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(1),
CollectionId: collection.Id,
},
},
collection: collection,
}
} else if fieldName == schema.FieldNameCreated || fieldName == schema.FieldNameUpdated {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeDate,
},
collection: collection,
}
} else if fieldName == schema.FieldNameUsername && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeText,
},
collection: collection,
}
} else if fieldName == schema.FieldNameEmail && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeEmail,
},
collection: collection,
}
} else if (fieldName == schema.FieldNameVerified || fieldName == schema.FieldNameEmailVisibility) && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeBool,
},
collection: collection,
}
} else {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
collection: collection,
}
}
}
return result, nil
}
func (dao *Dao) findCollectionsByIdentifiers(tables []identifier) (map[string]*models.Collection, error) {
names := make([]any, 0, len(tables))
for _, table := range tables {
if strings.Contains(table.alias, "(") {
continue // skip expressions
}
names = append(names, table.original)
}
if len(names) == 0 {
return nil, nil
}
result := make(map[string]*models.Collection, len(names))
collections := make([]*models.Collection, 0, len(names))
err := dao.CollectionQuery().
AndWhere(dbx.In("name", names...)).
All(&collections)
if err != nil {
return nil, err
}
for _, table := range tables {
for _, collection := range collections {
if collection.Name == table.original {
result[table.alias] = collection
}
}
}
return result, nil
}
// -------------------------------------------------------------------
// Raw query identifiers parser
// -------------------------------------------------------------------
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(inner join|outer join|left join|right join|join)\s+?`)
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group by|having|order|limit|with)\s+?`)
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
type identifier struct {
original string
alias string
}
type identifiersParser struct {
columns []identifier
tables []identifier
}
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(selectQuery, ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t')
tk.KeepSeparator(true)
var skip bool
var partType string
var activeBuilder *strings.Builder
var selectParts strings.Builder
var fromParts strings.Builder
var joinParts strings.Builder
for {
token, err := tk.Scan()
if err != nil {
if err != io.EOF {
return err
}
break
}
trimmed := strings.ToLower(strings.TrimSpace(token))
switch trimmed {
case "select":
skip = false
partType = "select"
activeBuilder = &selectParts
case "from":
skip = false
partType = "from"
activeBuilder = &fromParts
case "_join_":
skip = false
// the previous part was also a join
if partType == "join" {
joinParts.WriteString(",")
}
partType = "join"
activeBuilder = &joinParts
case "_discard_":
// do nothing...
skip = true
default:
isJoin := partType == "join"
if isJoin && trimmed == "on" {
skip = true
}
if !skip && activeBuilder != nil {
activeBuilder.WriteString(" ")
activeBuilder.WriteString(token)
}
}
}
selects, err := extractIdentifiers(selectParts.String())
if err != nil {
return err
}
froms, err := extractIdentifiers(fromParts.String())
if err != nil {
return err
}
joins, err := extractIdentifiers(joinParts.String())
if err != nil {
return err
}
p.columns = selects
p.tables = froms
p.tables = append(p.tables, joins...)
return nil
}
func extractIdentifiers(rawExpression string) ([]identifier, error) {
rawTk := tokenizer.NewFromString(rawExpression)
rawTk.Separators(',')
rawIdentifiers, err := rawTk.ScanAll()
if err != nil {
return nil, err
}
result := make([]identifier, 0, len(rawIdentifiers))
for _, rawIdentifier := range rawIdentifiers {
tk := tokenizer.NewFromString(rawIdentifier)
tk.Separators(' ', '\n', '\t')
parts, err := tk.ScanAll()
if err != nil {
return nil, err
}
resolved, err := identifierFromParts(parts)
if err != nil {
return nil, err
}
result = append(result, resolved)
}
return result, nil
}
func identifierFromParts(parts []string) (identifier, error) {
var result identifier
switch len(parts) {
case 3:
if !strings.EqualFold(parts[1], "as") {
return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
}
result.original = parts[0]
result.alias = parts[2]
case 2:
result.original = parts[0]
result.alias = parts[1]
case 1:
subParts := strings.Split(parts[0], ".")
result.original = parts[0]
result.alias = subParts[len(subParts)-1]
default:
return result, fmt.Errorf(`invalid identifier parts %v`, parts)
}
result.original = trimRawIdentifier(result.original)
result.alias = trimRawIdentifier(result.alias)
return result, nil
}
func trimRawIdentifier(rawIdentifier string) string {
const trimChars = "`\"[];"
parts := strings.Split(rawIdentifier, ".")
for i := range parts {
parts[i] = strings.Trim(parts[i], trimChars)
}
return strings.Join(parts, ".")
}
+539
View File
@@ -0,0 +1,539 @@
package daos_test
import (
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestDeleteView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
viewName string
expectError bool
}{
{"", true},
{"demo1", true}, // not a view table
{"missing", false}, // missing or already deleted
{"view1", false}, // existing
{"VieW1", false}, // view names are case insensitives
}
for i, s := range scenarios {
err := app.Dao().DeleteView(s.viewName)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
}
}
}
func TestSaveView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
scenarioName string
viewName string
query string
expectError bool
expectColumns []string
}{
{
"empty name and query",
"",
"",
true,
nil,
},
{
"empty name",
"",
"select * from _admins",
true,
nil,
},
{
"empty query",
"123Test",
"",
true,
nil,
},
{
"invalid query",
"123Test",
"123 456",
true,
nil,
},
{
"missing table",
"123Test",
"select * from missing",
true,
nil,
},
{
"non select query",
"123Test",
"drop table _admins",
true,
nil,
},
{
"multiple select queries",
"123Test",
"select *, count(id) as c from _admins; select * from demo1;",
true,
nil,
},
{
"try to break the parent parenthesis",
"123Test",
"select *, count(id) as c from `_admins`)",
true,
nil,
},
{
"simple select query (+ trimmed semicolon)",
"123Test",
";select *, count(id) as c from _admins;",
false,
[]string{
"id", "created", "updated",
"passwordHash", "tokenKey", "email",
"lastResetSentAt", "avatar", "c",
},
},
{
"update old view with new query",
"123Test",
"select 1 as test from _admins",
false,
[]string{"test"},
},
}
for _, s := range scenarios {
err := app.Dao().SaveView(s.viewName, s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
infoRows, err := app.Dao().GetTableInfo(s.viewName)
if err != nil {
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
continue
}
if len(s.expectColumns) != len(infoRows) {
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
continue
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
}
}
}
}
func TestCreateViewSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
query string
expectError bool
expectFields map[string]string // name-type pairs
}{
{
"empty query",
"",
true,
nil,
},
{
"invalid query",
"test 123456",
true,
nil,
},
{
"missing table",
"select * from missing",
true,
nil,
},
{
"query with wildcard column",
"select a.id, a.* from demo1 a",
true,
nil,
},
{
"query without id",
"select text, url, created, updated from demo1",
true,
nil,
},
{
"query with comments",
`
select
-- test single line
id,
text,
/* multi
line comment */
url, created, updated from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"url": schema.FieldTypeUrl,
},
},
{
"query with all fields and quoted identifiers",
`
select
"id",
"created",
"updated",
[text],
` + "`bool`" + `,
"url",
"select_one",
"select_many",
"file_one",
"demo1"."file_many",
` + "`demo1`." + "`number`" + ` number_alias,
"email",
"datetime",
"json",
"rel_one",
"rel_many"
from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"bool": schema.FieldTypeBool,
"url": schema.FieldTypeUrl,
"select_one": schema.FieldTypeSelect,
"select_many": schema.FieldTypeSelect,
"file_one": schema.FieldTypeFile,
"file_many": schema.FieldTypeFile,
"number_alias": schema.FieldTypeNumber,
"email": schema.FieldTypeEmail,
"datetime": schema.FieldTypeDate,
"json": schema.FieldTypeJson,
"rel_one": schema.FieldTypeRelation,
"rel_many": schema.FieldTypeRelation,
},
},
{
"query with indirect relations fields",
"select a.id, b.id as bid, b.created from demo1 as a left join demo2 b",
false,
map[string]string{
"bid": schema.FieldTypeRelation,
},
},
{
"query with multiple froms, joins and style of aliasses",
`
select
a.id as id,
b.id as bid,
lj.id cid,
ij.id as did,
a.bool,
_admins.id as eid,
_admins.email
from demo1 a, demo2 as b
left join demo3 lj on lj.id = 123
inner join demo4 as ij on ij.id = 123
join _admins
where 1=1
group by a.id
limit 10
`,
false,
map[string]string{
"bid": schema.FieldTypeRelation,
"cid": schema.FieldTypeRelation,
"did": schema.FieldTypeRelation,
"bool": schema.FieldTypeBool,
"eid": schema.FieldTypeJson, // not from collection
"email": schema.FieldTypeJson, // not from collection
},
},
{
"query with numeric casts",
`select
a.id,
count(a.id) count,
cast(a.id as int) cast_int,
cast(a.id as integer) cast_integer,
cast(a.id as real) cast_real,
cast(a.id as decimal) cast_decimal,
cast(a.id as numeric) cast_numeric,
avg(a.id) avg,
sum(a.id) sum,
total(a.id) total,
min(a.id) min,
max(a.id) max
from demo1 a`,
false,
map[string]string{
"count": schema.FieldTypeNumber,
"total": schema.FieldTypeNumber,
"cast_int": schema.FieldTypeNumber,
"cast_integer": schema.FieldTypeNumber,
"cast_real": schema.FieldTypeNumber,
"cast_decimal": schema.FieldTypeNumber,
"cast_numeric": schema.FieldTypeNumber,
// json because they are nullable
"sum": schema.FieldTypeJson,
"avg": schema.FieldTypeJson,
"min": schema.FieldTypeJson,
"max": schema.FieldTypeJson,
},
},
{
"query with reserved auth collection fields",
`
select
a.id,
a.username,
a.email,
a.emailVisibility,
a.verified,
demo1.id relid
from users a
left join demo1
`,
false,
map[string]string{
"username": schema.FieldTypeText,
"email": schema.FieldTypeEmail,
"emailVisibility": schema.FieldTypeBool,
"verified": schema.FieldTypeBool,
"relid": schema.FieldTypeRelation,
},
},
{
"query with unknown fields and aliases",
`select
id,
id as id2,
text as text_alias,
url as url_alias,
"demo1"."bool" as bool_alias,
number as number_alias,
created created_alias,
updated updated_alias,
123 as custom
from demo1
`,
false,
map[string]string{
"id2": schema.FieldTypeJson,
"text_alias": schema.FieldTypeText,
"url_alias": schema.FieldTypeUrl,
"bool_alias": schema.FieldTypeBool,
"number_alias": schema.FieldTypeNumber,
"created_alias": schema.FieldTypeDate,
"updated_alias": schema.FieldTypeDate,
"custom": schema.FieldTypeJson,
},
},
}
for _, s := range scenarios {
result, err := app.Dao().CreateViewSchema(s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if len(s.expectFields) != len(result.Fields()) {
serialized, _ := json.Marshal(result)
t.Errorf("[%s] Expected %d fields, got %d: \n%s", s.name, len(s.expectFields), len(result.Fields()), serialized)
continue
}
for name, typ := range s.expectFields {
field := result.GetFieldByName(name)
if field == nil {
t.Errorf("[%s] Expected to find field %s, got nil", s.name, name)
continue
}
if field.Type != typ {
t.Errorf("[%s] Expected field %s to be %q, got %s", s.name, name, typ, field.Type)
continue
}
}
}
}
func TestFindRecordByViewFile(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
prevCollection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
totalLevels := 6
// create collection view mocks
fileOneAlias := "file_one one0"
fileManyAlias := "file_many many0"
mockCollections := make([]*models.Collection, 0, totalLevels)
for i := 0; i <= totalLevels; i++ {
view := new(models.Collection)
view.Type = models.CollectionTypeView
view.Name = fmt.Sprintf("_test_view%d", i)
view.SetOptions(&models.CollectionViewOptions{
Query: fmt.Sprintf(
"select id, %s, %s from %s",
fileOneAlias,
fileManyAlias,
prevCollection.Name,
),
})
// save view
if err := app.Dao().SaveCollection(view); err != nil {
t.Fatalf("Failed to save view%d: %v", i, err)
}
mockCollections = append(mockCollections, view)
prevCollection = view
fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1)
fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1)
}
fileOneName := "test_d61b33QdDU.txt"
fileManyName := "test_QZFjKjXchk.txt"
expectedRecordId := "84nmscqy84lsi1t"
scenarios := []struct {
name string
collectionNameOrId string
fileFieldName string
filename string
expectError bool
expectRecordId string
}{
{
"missing collection",
"missing",
"a",
fileOneName,
true,
"",
},
{
"non-view collection",
"demo1",
"file_one",
fileOneName,
true,
"",
},
{
"view collection after the max recursion limit",
mockCollections[totalLevels-1].Name,
fmt.Sprintf("one%d", totalLevels-1),
fileOneName,
true,
"",
},
{
"first view collection (single file)",
mockCollections[0].Name,
"one0",
fileOneName,
false,
expectedRecordId,
},
{
"first view collection (many files)",
mockCollections[0].Name,
"many0",
fileManyName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (single file)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("one%d", totalLevels-2),
fileOneName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (many files)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("many%d", totalLevels-2),
fileManyName,
false,
expectedRecordId,
},
}
for _, s := range scenarios {
record, err := app.Dao().FindRecordByViewFile(
s.collectionNameOrId,
s.fileFieldName,
s.filename,
)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if record.Id != s.expectRecordId {
t.Errorf("[%s] Expected recordId %q, got %q", s.name, s.expectRecordId, record.Id)
}
}
}
+14
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
@@ -52,6 +53,14 @@ func main() {
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
)
var queryTimeout int
app.RootCmd.PersistentFlags().IntVar(
&queryTimeout,
"queryTimeout",
30,
"the default SELECT queries timeout in seconds",
)
app.RootCmd.ParseFlags(os.Args[1:])
// ---------------------------------------------------------------
@@ -70,6 +79,11 @@ func main() {
Dir: migrationsDir,
})
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
return nil
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// serves static files from the provided public dir (if exists)
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
+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)
}
+146 -36
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"
@@ -68,7 +69,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
}
clone, _ := form.collection.Schema.Clone()
if clone != nil {
if clone != nil && form.Type != models.CollectionTypeView {
form.Schema = *clone
} else {
form.Schema = schema.Schema{}
@@ -85,6 +86,16 @@ func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error {
isAuth := form.Type == models.CollectionTypeAuth
isView := form.Type == models.CollectionTypeView
// generate schema from the query (overwriting any explicit user defined schema)
if isView {
options := models.CollectionViewOptions{}
if err := decodeOptions(form.Options, &options); err != nil {
return err
}
form.Schema, _ = form.dao.CreateViewSchema(options.Query)
}
return validation.ValidateStruct(form,
validation.Field(
@@ -93,6 +104,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(
@@ -102,7 +114,11 @@ func (form *CollectionUpsert) Validate() error {
validation.Field(
&form.Type,
validation.Required,
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
validation.In(
models.CollectionTypeBase,
models.CollectionTypeAuth,
models.CollectionTypeView,
),
validation.By(form.ensureNoTypeChange),
),
validation.Field(
@@ -113,23 +129,32 @@ func (form *CollectionUpsert) Validate() error {
validation.By(form.ensureNoSystemNameChange),
validation.By(form.checkUniqueName),
),
// validates using the type's own validation rules + some collection's specific
// validates using the type's own validation rules + some collection's specifics
validation.Field(
&form.Schema,
validation.By(form.checkMinSchemaFields),
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureExistingRelationCollectionId),
validation.When(
isAuth,
validation.By(form.ensureNoAuthFieldName),
),
validation.By(form.checkRelationFields),
validation.When(isAuth, validation.By(form.ensureNoAuthFieldName)),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
validation.Field(
&form.CreateRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.UpdateRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.DeleteRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(&form.Options, validation.By(form.checkOptions)),
)
}
@@ -202,9 +227,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
@@ -212,14 +245,56 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
options, _ := field.Options.(*schema.RelationOptions)
if options == nil {
continue
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.NewError(
"validation_schema_invalid_relation_field_options",
"The relation field has invalid field options.",
)},
}
}
if _, err := form.dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
)}
// prevent collectionId change
oldField := form.collection.Schema.GetFieldById(field.Id)
if oldField != nil {
oldOptions, _ := oldField.Options.(*schema.RelationOptions)
if oldOptions != nil && oldOptions.CollectionId != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_relation_change",
"The relation collection cannot be changed.",
),
}},
}
}
}
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),
),
}},
}
}
}
}
@@ -257,13 +332,15 @@ func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
}
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
if form.Type == models.CollectionTypeAuth {
return nil // auth collections doesn't require having additional schema fields
}
v, _ := value.(schema.Schema)
v, ok := value.(schema.Schema)
if !ok || len(v.Fields()) == 0 {
return validation.ErrRequired
switch form.Type {
case models.CollectionTypeAuth, models.CollectionTypeView:
return nil // no schema fields constraint
default:
if len(v.Fields()) == 0 {
return validation.ErrRequired
}
}
return nil
@@ -312,15 +389,11 @@ func (form *CollectionUpsert) checkRule(value any) error {
func (form *CollectionUpsert) checkOptions(value any) error {
v, _ := value.(types.JsonMap)
if form.Type == models.CollectionTypeAuth {
raw, err := v.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
switch form.Type {
case models.CollectionTypeAuth:
options := models.CollectionAuthOptions{}
if err := json.Unmarshal(raw, &options); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
if err := decodeOptions(v, &options); err != nil {
return err
}
// check the generic validations
@@ -332,6 +405,39 @@ func (form *CollectionUpsert) checkOptions(value any) error {
if err := form.checkRule(options.ManageRule); err != nil {
return validation.Errors{"manageRule": err}
}
case models.CollectionTypeView:
options := models.CollectionViewOptions{}
if err := decodeOptions(v, &options); err != nil {
return err
}
// check the generic validations
if err := options.Validate(); err != nil {
return err
}
// check the query option
if _, err := form.dao.CreateViewSchema(options.Query); err != nil {
return validation.Errors{
"query": validation.NewError(
"validation_invalid_view_query",
fmt.Sprintf("Invalid query - %s", err.Error()),
),
}
}
}
return nil
}
func decodeOptions(options types.JsonMap, result any) error {
raw, err := options.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
if err := json.Unmarshal(raw, result); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
return nil
@@ -343,7 +449,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
}
@@ -367,7 +473,11 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.Name = form.Name
}
form.collection.Schema = form.Schema
// view schema is autogenerated on save
if !form.collection.IsView() {
form.collection.Schema = form.Schema
}
form.collection.ListRule = form.ListRule
form.collection.ViewRule = form.ViewRule
form.collection.CreateRule = form.CreateRule
@@ -375,7 +485,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...)
}
+175 -15
View File
@@ -22,7 +22,7 @@ func TestNewCollectionUpsert(t *testing.T) {
collection.Name = "test_name"
collection.Type = "test_type"
collection.System = true
listRule := "testview"
listRule := "test_list"
collection.ListRule = &listRule
viewRule := "test_view"
collection.ViewRule = &viewRule
@@ -98,6 +98,7 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}{
{"empty create (base)", "", "{}", []string{"name", "schema"}},
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
{"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
{"empty update", "demo2", "{}", []string{}},
{
"create failure",
@@ -169,7 +170,26 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
[]string{"schema"},
},
{
"create failure - check type options validators",
"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 auth options validators",
"",
`{
"name": "test_new",
@@ -181,6 +201,16 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"options"},
},
{
"create failure - check view options validators",
"",
`{
"name": "test_new",
"type": "view",
"options": { "query": "invalid query" }
}`,
[]string{"options"},
},
{
"create success",
"",
@@ -190,7 +220,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",
@@ -253,6 +291,26 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"type"},
},
{
"update failure - changing relation collection",
"users",
`{
"schema": [
{
"id": "lkeigvv3",
"name": "rel",
"type": "relation",
"options": {
"collectionId": "wzlqyes4orhoygb",
"cascadeDelete": false,
"maxSelect": 1,
"displayFields": null
}
}
]
}`,
[]string{"schema"},
},
{
"update failure - all fields",
"demo2",
@@ -329,6 +387,99 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{},
},
// view tests
// -----------------------------------------------------------
{
"view create failure",
"",
`{
"name": "upsert_view",
"type": "view",
"listRule": "id='123' && verified = true",
"viewRule": "id='123' && emailVisibility = true",
"schema": [
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
],
"options": {
"query": "select id, email from users; drop table _admins;"
}
}`,
[]string{
"listRule",
"viewRule",
"options",
},
},
{
"view create success",
"",
`{
"name": "upsert_view",
"type": "view",
"listRule": "id='123' && verified = true",
"viewRule": "id='123' && emailVisibility = true",
"schema": [
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
],
"options": {
"query": "select id, emailVisibility, verified from users"
}
}`,
[]string{
// "schema", should be overwritten by an autogenerated from the query
},
},
{
"view update failure (schema autogeneration and rule fields check)",
"upsert_view",
`{
"name": "upsert_view_2",
"listRule": "id='456' && verified = true",
"viewRule": "id='456'",
"createRule": "id='123'",
"updateRule": "id='123'",
"deleteRule": "id='123'",
"schema": [
{"id":"abc123","name":"verified","type":"bool"}
],
"options": {
"query": "select 1 as id"
}
}`,
[]string{
"listRule", // missing field (ignoring the old or explicit schema)
"createRule", // not allowed
"updateRule", // not allowed
"deleteRule", // not allowed
},
},
{
"view update failure (check query identifiers format)",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as [invalid!@#]"
}
}`,
[]string{
"schema", // should fail due to invalid field name
},
},
{
"view update success",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as valid"
}
}`,
[]string{},
},
}
for _, s := range scenarios {
@@ -351,10 +502,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)
}
}
@@ -427,10 +578,19 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
}
formSchema, _ := form.Schema.MarshalJSON()
collectionSchema, _ := collection.Schema.MarshalJSON()
if string(formSchema) != string(collectionSchema) {
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
rawFormSchema, _ := form.Schema.MarshalJSON()
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
continue
}
for _, f := range form.Schema.Fields() {
if collection.Schema.GetFieldByName(f.Name) == nil {
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
continue
}
}
}
}
@@ -451,16 +611,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,
)
+16 -14
View File
@@ -38,6 +38,8 @@ func TestCollectionsImportValidate(t *testing.T) {
}
func TestCollectionsImportSubmit(t *testing.T) {
totalCollections := 10
scenarios := []struct {
name string
jsonData string
@@ -52,7 +54,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": []
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: nil,
},
{
@@ -82,7 +84,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
@@ -101,7 +103,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
@@ -137,7 +139,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
expectCollectionsCount: 10,
expectCollectionsCount: totalCollections + 3,
expectEvents: map[string]int{
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
@@ -160,7 +162,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
@@ -202,7 +204,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 5,
},
@@ -253,7 +255,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
expectCollectionsCount: 9,
expectCollectionsCount: totalCollections + 2,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
@@ -341,8 +343,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnModelBeforeDelete": 5,
"OnModelAfterDelete": 5,
"OnModelBeforeDelete": totalCollections - 2,
"OnModelAfterDelete": totalCollections - 2,
},
},
}
@@ -404,16 +406,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
+67 -68
View File
@@ -7,7 +7,6 @@ import (
"log"
"net/http"
"regexp"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -219,11 +218,6 @@ func (form *RecordUpsert) extractMultipartFormData(
// and lods it into the form.
//
// File upload is supported only via multipart/form-data.
//
// To DELETE previously uploaded file(s) you can suffix the field name
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
// reset the field using its field name (eg. `myfile = null`).
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
if err != nil {
@@ -241,6 +235,16 @@ func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
return nil
}
// FilesToUpload returns the parsed request files ready for upload.
func (form *RecordUpsert) FilesToUpload() map[string][]*filesystem.File {
return form.filesToUpload
}
// FilesToUpload returns the parsed request filenames ready to be deleted.
func (form *RecordUpsert) FilesToDelete() []string {
return form.filesToDelete
}
// AddFiles adds the provided file(s) to the specified file field.
//
// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
@@ -252,9 +256,9 @@ func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
//
// Example
//
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
// form.AddFiles("documents", f1, f2)
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
// form.AddFiles("documents", f1, f2)
func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile {
@@ -304,11 +308,11 @@ func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error
//
// Example
//
// // mark only only 2 files for removal
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
// // mark only only 2 files for removal
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
//
// // mark all "documents" files for removal
// form.AddFiles("documents")
// // mark all "documents" files for removal
// form.AddFiles("documents")
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile {
@@ -345,29 +349,24 @@ func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
}
// LoadData loads and normalizes the provided regular record data fields into the form.
//
// To DELETE previously uploaded file(s) you can suffix the field name
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
// reset the field using its field name (eg. `myfile = null`).
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
// load base system fields
if v, ok := requestData["id"]; ok {
if v, ok := requestData[schema.FieldNameId]; ok {
form.Id = cast.ToString(v)
}
// load auth system fields
if form.record.Collection().IsAuth() {
if v, ok := requestData["username"]; ok {
if v, ok := requestData[schema.FieldNameUsername]; ok {
form.Username = cast.ToString(v)
}
if v, ok := requestData["email"]; ok {
if v, ok := requestData[schema.FieldNameEmail]; ok {
form.Email = cast.ToString(v)
}
if v, ok := requestData["emailVisibility"]; ok {
if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
form.EmailVisibility = cast.ToBool(v)
}
if v, ok := requestData["verified"]; ok {
if v, ok := requestData[schema.FieldNameVerified]; ok {
form.Verified = cast.ToBool(v)
}
if v, ok := requestData["password"]; ok {
@@ -381,8 +380,16 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
}
}
// extend the record schema data with the request data
extendedData := form.record.SchemaData()
// replace modifiers (if any)
requestData = form.record.ReplaceModifers(requestData)
// create a shallow copy of form.data
var extendedData = make(map[string]any, len(form.data))
for k, v := range form.data {
extendedData[k] = v
}
// extend form.data with the request data
rawData, err := json.Marshal(requestData)
if err != nil {
return err
@@ -393,8 +400,7 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
for _, field := range form.record.Collection().Schema.Fields() {
key := field.Name
value := extendedData[key]
value = field.PrepareValue(value)
value := field.PrepareValue(extendedData[key])
if field.Type != schema.FieldTypeFile {
form.data[key] = value
@@ -405,30 +411,31 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
// Delete previously uploaded file(s)
// -----------------------------------------------------------
oldNames := list.ToUniqueStringSlice(form.data[key])
oldNames := form.record.GetStringSlice(key)
submittedNames := list.ToUniqueStringSlice(value)
// ensure that all submitted names are existing to prevent accidental files deletions
if len(submittedNames) > len(oldNames) || len(list.SubtractSlice(submittedNames, oldNames)) != 0 {
return validation.Errors{
key: validation.NewError(
"validation_unknown_filenames",
"The field contains unknown filenames.",
),
}
}
// if empty value was set, mark all previously uploaded files for deletion
if len(list.ToUniqueStringSlice(value)) == 0 && len(oldNames) > 0 {
// otherwise check for "deleted" (aka. unsubmitted) file names
if len(submittedNames) == 0 && len(oldNames) > 0 {
form.RemoveFiles(key)
} else if len(oldNames) > 0 {
toDelete := []string{}
// search for individual file name to delete (eg. "file.test.png = null")
for _, name := range oldNames {
if v, ok := extendedData[key+"."+name]; ok && cast.ToString(v) == "" {
// submitted as a modifier or a new array
if !list.ExistInSlice(name, submittedNames) {
toDelete = append(toDelete, name)
}
}
// search for individual file index to delete (eg. "file.0 = null")
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
for indexedKey := range extendedData {
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
if indexErr != nil || index >= len(oldNames) {
continue
}
toDelete = append(toDelete, oldNames[index])
continue
}
}
@@ -436,6 +443,12 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
form.RemoveFiles(key, toDelete...)
}
}
// allow file key reasignments for file names sorting
// (only if all submitted values already exists)
if len(submittedNames) > 0 && len(list.SubtractSlice(submittedNames, oldNames)) == 0 {
form.data[key] = submittedNames
}
}
return nil
@@ -451,6 +464,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)),
),
}
@@ -462,7 +476,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),
),
@@ -713,32 +727,29 @@ 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()
}
// persist the record model
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
return fmt.Errorf("failed to save the record: %w", saveErr)
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return fmt.Errorf("failed to process the uploaded files: %w", err)
}
// persist the record model
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
// try to cleanup the successfully uploaded files
if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() {
log.Println(err)
}
return fmt.Errorf("failed to save the record: %w", saveErr)
}
// delete old files (if any)
//
// for now fail silently to avoid reupload when `form.Submit()`
@@ -751,18 +762,6 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
}, interceptors...)
}
func (form *RecordUpsert) getFilesToUploadNames() []string {
names := []string{}
for fieldKey := range form.filesToUpload {
for _, file := range form.filesToUpload[fieldKey] {
names = append(names, file.Name)
}
}
return names
}
func (form *RecordUpsert) processFilesToUpload() error {
if len(form.filesToUpload) == 0 {
return nil // no parsed file fields
+57 -33
View File
@@ -89,9 +89,10 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
"unknown": "test456",
// file fields unset/delete
"file_one": nil,
"file_many.0": "", // delete by index
"file_many.1": "test.png", // should be ignored
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
"file_many.0": "", // delete by index
"file_many-": []string{"test_MaWC6mWyrP.txt", "test_tC1Yc87DfC.txt"}, // multiple delete with modifier
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
"file_many.2": "test.png", // should be ignored
},
},
}
@@ -149,11 +150,12 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
"a.b.text": "test123",
"a.b.unknown": "test456",
// file fields unset/delete
"a.b.file_one": "",
"a.b.file_many.0": "",
"a.b.file_many.300_WlbFWSGmW9.png": "test.png", // delete by name
"a.b.file_many.1": "test.png", // should be ignored
}, "file_many")
"a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier
"a.b.file_many.0": "", // delete by index
"a.b.file_many-": "test_tC1Yc87DfC.txt", // delete with modifier
"a.b.file_many.300_WlbFWSGmW9.png": "", // delete by filename
"a.b.file_many.2": "test.png", // should be ignored
}, "a.b.file_many")
if err != nil {
t.Fatal(err)
}
@@ -191,7 +193,7 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
t.Fatal("Expect file_many field to be set")
}
manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
expectedRemains := 2 // -2 from 3 removed + 1 new upload
expectedRemains := 3 // 5 old; 3 deleted and 1 new uploaded
if manyfilesRemains != expectedRemains {
t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany)
}
@@ -426,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)
}
}
@@ -465,7 +467,7 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
if v := recordAfter.Get("email"); v == "invalid" {
t.Fatalf("Expected record.email not to change, got %v", v)
}
if v := recordAfter.GetStringSlice("file_many"); len(v) != 3 {
if v := recordAfter.GetStringSlice("file_many"); len(v) != 5 {
t.Fatalf("Expected record.file_many not to change, got %v", v)
}
@@ -503,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)
}
}
@@ -537,8 +539,8 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
}
fileMany := (recordAfter.GetStringSlice("file_many"))
if len(fileMany) != 4 { // 1 replace + 1 new
t.Fatalf("Expected 4 record.file_many, got %d (%v)", len(fileMany), fileMany)
if len(fileMany) != 6 { // 1 replace + 1 new
t.Fatalf("Expected 6 record.file_many, got %d (%v)", len(fileMany), fileMany)
}
for _, f := range fileMany {
if !hasRecordFile(app, recordAfter, f) {
@@ -564,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
@@ -778,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",
},
@@ -977,11 +979,33 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
t.Fatal(err)
}
removed0 := "test_d61b33QdDU.txt" // replaced
removed1 := "300_WlbFWSGmW9.png"
removed2 := "logo_vcfJJG5TAh.svg"
form.AddFiles("file_one", f1) // should replace the existin file
form.AddFiles("file_many", f2, f3) // should append
form.RemoveFiles("file_many", "300_WlbFWSGmW9.png", "logo_vcfJJG5TAh.svg") // should remove
form.RemoveFiles("file_many", removed1, removed2) // should remove
filesToUpload := form.FilesToUpload()
if v, ok := filesToUpload["file_one"]; !ok || len(v) != 1 {
t.Fatalf("Expected filesToUpload[file_one] to have exactly 1 file, got %v", v)
}
if v, ok := filesToUpload["file_many"]; !ok || len(v) != 2 {
t.Fatalf("Expected filesToUpload[file_many] to have exactly 2 file, got %v", v)
}
filesToDelete := form.FilesToDelete()
if len(filesToDelete) != 3 {
t.Fatalf("Expected exactly 2 file to delete, got %v", filesToDelete)
}
for _, f := range []string{removed0, removed1, removed2} {
if !list.ExistInSlice(f, filesToDelete) {
t.Fatalf("Missing file %q from filesToDelete %v", f, filesToDelete)
}
}
if err := form.Submit(); err != nil {
t.Fatalf("Failed to submit the RecordUpsert form, got %v", err)
@@ -993,14 +1017,14 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
}
// ensure files deletion
if hasRecordFile(app, recordAfter, "test_d61b33QdDU.txt") {
if hasRecordFile(app, recordAfter, removed0) {
t.Fatalf("Expected the old file_one file to be deleted")
}
if hasRecordFile(app, recordAfter, "300_WlbFWSGmW9.png") {
t.Fatalf("Expected 300_WlbFWSGmW9.png to be deleted")
if hasRecordFile(app, recordAfter, removed1) {
t.Fatalf("Expected %s to be deleted", removed1)
}
if hasRecordFile(app, recordAfter, "logo_vcfJJG5TAh.svg") {
t.Fatalf("Expected logo_vcfJJG5TAh.svg to be deleted")
if hasRecordFile(app, recordAfter, removed2) {
t.Fatalf("Expected %s to be deleted", removed2)
}
fileOne := recordAfter.GetStringSlice("file_one")
@@ -1009,7 +1033,7 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
}
fileMany := recordAfter.GetStringSlice("file_many")
if len(fileMany) != 3 {
t.Fatalf("Expected file_many to be 3, got %v", fileMany)
if len(fileMany) != 5 {
t.Fatalf("Expected file_many to be 5, got %v", fileMany)
}
}
+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
}
+23 -9
View File
@@ -13,6 +13,7 @@ import (
// size is no more than the provided maxBytes.
//
// Example:
//
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
func UploadedFileSize(maxBytes int) validation.RuleFunc {
return func(value any) error {
@@ -22,7 +23,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
@@ -33,7 +37,8 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
// mimetype is within the provided allowed mime types.
//
// Example:
// validMimeTypes := []string{"test/plain","image/jpeg"}
//
// validMimeTypes := []string{"test/plain","image/jpeg"}
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
return func(value any) error {
@@ -42,19 +47,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 +73,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, ", "),
),
)
}
}
+39
View File
@@ -0,0 +1,39 @@
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)
}
}
}
+22 -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
}
@@ -349,6 +362,10 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
options, _ := field.Options.(*schema.RelationOptions)
if options.MinSelect != nil && len(ids) < *options.MinSelect {
return validation.NewError("validation_not_enough_values", fmt.Sprintf("Select at least %d", *options.MinSelect))
}
if options.MaxSelect != nil && len(ids) > *options.MaxSelect {
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", *options.MaxSelect))
}
+27 -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",
@@ -1202,6 +1212,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Unique: true,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MinSelect: types.Pointer(2),
CollectionId: demo.Id,
},
},
@@ -1248,6 +1259,15 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
nil,
[]string{"field2"},
},
{
"check min constraint",
map[string]any{
"field2": relId2,
"field3": []string{relId1},
},
nil,
[]string{"field3"},
},
{
"check unique constraint",
map[string]any{
+1
View File
@@ -7,6 +7,7 @@ import (
// Compare checks whether the validated value matches another string.
//
// Example:
//
// validation.Field(&form.PasswordConfirm, validation.By(validators.Compare(form.Password)))
func Compare(valueToCompare string) validation.RuleFunc {
return func(value any) error {
+40 -40
View File
@@ -4,51 +4,51 @@ go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/aws/aws-sdk-go v1.44.161
github.com/aws/aws-sdk-go v1.44.209
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.3.4
github.com/domodwyer/mailyak/v3 v3.4.0
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.1.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/golang-jwt/jwt/v4 v4.5.0
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.10.0
github.com/spf13/cast v1.5.0
github.com/spf13/cobra v1.6.1
gocloud.dev v0.27.0
golang.org/x/crypto v0.4.0
golang.org/x/net v0.4.0
golang.org/x/oauth2 v0.3.0
golang.org/x/sync v0.1.0
modernc.org/sqlite v1.20.0
gocloud.dev v0.28.0
golang.org/x/crypto v0.6.0
golang.org/x/net v0.7.0
golang.org/x/oauth2 v0.5.0
modernc.org/sqlite v1.21.0
)
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 v1.17.5 // 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.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.5 // 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.44 // 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
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.55 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
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.29.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.27 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.17.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.5 // 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.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
@@ -59,30 +59,30 @@ require (
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // 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-20230129092748-24d4a6f8daec // 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
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.2.0 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/term v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/image v0.5.0 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.4.0 // indirect
golang.org/x/tools v0.6.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.105.0 // indirect
google.golang.org/api v0.110.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect
google.golang.org/grpc v1.53.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.21.5 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
+622 -187
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -56,17 +56,16 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: admin.Email},
To: []mail.Address{{Address: admin.Email}},
Subject: "Reset admin password",
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)
+23 -23
View File
@@ -32,17 +32,17 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: authRecord.Email()},
To: []mail.Address{{Address: authRecord.Email()}},
Subject: subject,
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)
@@ -76,17 +76,17 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: authRecord.Email()},
To: []mail.Address{{Address: authRecord.Email()}},
Subject: subject,
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)
@@ -120,19 +120,19 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: newEmail},
To: []mail.Address{{Address: newEmail}},
Subject: subject,
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 -1
View File
@@ -128,11 +128,11 @@ func init() {
MaxSelect: 1,
MaxSize: 5242880,
MimeTypes: []string{
"image/jpg",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp",
},
},
},
@@ -0,0 +1,215 @@
package migrations
import (
"regexp"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// This migration replaces for backward compatibility the default operators
// (=, !=, >, etc.) with their any/opt equivalent (?=, ?=, ?>, etc.)
// in any muli-rel expression collection rule.
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
exprRegex := regexp.MustCompile(`([\@\'\"\w\.]+)\s*(=|!=|~|!~|>|>=|<|<=)\s*([\@\'\"\w\.]+)`)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
findCollection := func(nameOrId string) *models.Collection {
for _, c := range collections {
if c.Id == nameOrId || c.Name == nameOrId {
return c
}
}
return nil
}
var isMultiRelLiteral func(mainCollection *models.Collection, literal string) bool
isMultiRelLiteral = func(mainCollection *models.Collection, literal string) bool {
if strings.HasPrefix(literal, "@collection.") {
return true
}
if strings.HasPrefix(literal, `"`) ||
strings.HasPrefix(literal, `'`) ||
strings.HasPrefix(literal, "@request.method") ||
strings.HasPrefix(literal, "@request.data") ||
strings.HasPrefix(literal, "@request.query") {
return false
}
parts := strings.Split(literal, ".")
if len(parts) <= 1 {
return false
}
if strings.HasPrefix(literal, "@request.auth") && len(parts) >= 4 {
// check each auth collection
for _, c := range collections {
if c.IsAuth() && isMultiRelLiteral(c, strings.Join(parts[2:], ".")) {
return true
}
}
return false
}
activeCollection := mainCollection
for i, p := range parts {
f := activeCollection.Schema.GetFieldByName(p)
if f == nil || f.Type != schema.FieldTypeRelation {
return false // not a relation field
}
// is multi-relation and not the last prop
opt, ok := f.Options.(*schema.RelationOptions)
if ok && (opt.MaxSelect == nil || *opt.MaxSelect != 1) && i != len(parts)-1 {
return true
}
activeCollection = findCollection(opt.CollectionId)
if activeCollection == nil {
return false
}
}
return false
}
// replace all multi-match operators to their any/opt equivalent, eg. "=" => "?="
migrateRule := func(collection *models.Collection, rule *string) (*string, error) {
if rule == nil || *rule == "" {
return rule, nil
}
newRule := *rule
parts := exprRegex.FindAllStringSubmatch(newRule, -1)
for _, p := range parts {
if isMultiRelLiteral(collection, p[1]) || isMultiRelLiteral(collection, p[3]) {
newRule = strings.ReplaceAll(newRule, p[0], p[1]+" ?"+p[2]+" "+p[3])
}
}
return &newRule, nil
}
var ruleErr error
for _, c := range collections {
c.ListRule, ruleErr = migrateRule(c, c.ListRule)
if ruleErr != nil {
return ruleErr
}
c.ViewRule, ruleErr = migrateRule(c, c.ViewRule)
if ruleErr != nil {
return ruleErr
}
c.CreateRule, ruleErr = migrateRule(c, c.CreateRule)
if ruleErr != nil {
return ruleErr
}
c.UpdateRule, ruleErr = migrateRule(c, c.UpdateRule)
if ruleErr != nil {
return ruleErr
}
c.DeleteRule, ruleErr = migrateRule(c, c.DeleteRule)
if ruleErr != nil {
return ruleErr
}
if c.IsAuth() {
opt := c.AuthOptions()
opt.ManageRule, ruleErr = migrateRule(c, opt.ManageRule)
if ruleErr != nil {
return ruleErr
}
c.SetOptions(opt)
}
if err := dao.Save(c); err != nil {
return err
}
}
return nil
}, func(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
anyOpRegex := regexp.MustCompile(`\?(=|!=|~|!~|>|>=|<|<=)`)
// replace any/opt operators to their old versions, eg. "?=" => "="
revertRule := func(rule *string) (*string, error) {
if rule == nil || *rule == "" {
return rule, nil
}
newRule := *rule
newRule = anyOpRegex.ReplaceAllString(newRule, "${1}")
return &newRule, nil
}
var ruleErr error
for _, c := range collections {
c.ListRule, ruleErr = revertRule(c.ListRule)
if ruleErr != nil {
return ruleErr
}
c.ViewRule, ruleErr = revertRule(c.ViewRule)
if ruleErr != nil {
return ruleErr
}
c.CreateRule, ruleErr = revertRule(c.CreateRule)
if ruleErr != nil {
return ruleErr
}
c.UpdateRule, ruleErr = revertRule(c.UpdateRule)
if ruleErr != nil {
return ruleErr
}
c.DeleteRule, ruleErr = revertRule(c.DeleteRule)
if ruleErr != nil {
return ruleErr
}
if c.IsAuth() {
opt := c.AuthOptions()
opt.ManageRule, ruleErr = revertRule(opt.ManageRule)
if ruleErr != nil {
return ruleErr
}
c.SetOptions(opt)
}
if err := dao.Save(c); err != nil {
return err
}
}
return nil
})
}
@@ -0,0 +1,26 @@
package migrations
import (
"github.com/pocketbase/dbx"
)
// This migration replaces the "authentikAuth" setting with "oidc".
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
_, err := db.NewQuery(`
UPDATE {{_params}}
SET [[value]] = replace([[value]], '"authentikAuth":', '"oidcAuth":')
WHERE [[key]] = 'settings'
`).Execute()
return err
}, func(db dbx.Builder) error {
_, err := db.NewQuery(`
UPDATE {{_params}}
SET [[value]] = replace([[value]], '"oidcAuth":', '"authentikAuth":')
WHERE [[key]] = 'settings'
`).Execute()
return err
})
}
+34 -2
View File
@@ -17,6 +17,7 @@ var (
const (
CollectionTypeBase = "base"
CollectionTypeAuth = "auth"
CollectionTypeView = "view"
)
type Collection struct {
@@ -52,11 +53,16 @@ func (m *Collection) IsBase() bool {
return m.Type == CollectionTypeBase
}
// IsBase checks if the current collection has "auth" type.
// IsAuth checks if the current collection has "auth" type.
func (m *Collection) IsAuth() bool {
return m.Type == CollectionTypeAuth
}
// IsView checks if the current collection has "view" type.
func (m *Collection) IsView() bool {
return m.Type == CollectionTypeView
}
// MarshalJSON implements the [json.Marshaler] interface.
func (m Collection) MarshalJSON() ([]byte, error) {
type alias Collection // prevent recursion
@@ -82,6 +88,14 @@ func (m *Collection) AuthOptions() CollectionAuthOptions {
return result
}
// ViewOptions decodes the current collection options and returns them
// as new [CollectionViewOptions] instance.
func (m *Collection) ViewOptions() CollectionViewOptions {
result := CollectionViewOptions{}
m.DecodeOptions(&result)
return result
}
// NormalizeOptions updates the current collection options with a
// new normalized state based on the collection type.
func (m *Collection) NormalizeOptions() error {
@@ -89,6 +103,8 @@ func (m *Collection) NormalizeOptions() error {
switch m.Type {
case CollectionTypeAuth:
typedOptions = m.AuthOptions()
case CollectionTypeView:
typedOptions = m.ViewOptions()
default:
typedOptions = m.BaseOptions()
}
@@ -143,7 +159,7 @@ func (m *Collection) SetOptions(typedOptions any) error {
// -------------------------------------------------------------------
// CollectionAuthOptions defines the "base" Collection.Options fields.
// CollectionBaseOptions defines the "base" Collection.Options fields.
type CollectionBaseOptions struct {
}
@@ -152,6 +168,8 @@ func (o CollectionBaseOptions) Validate() error {
return nil
}
// -------------------------------------------------------------------
// CollectionAuthOptions defines the "auth" Collection.Options fields.
type CollectionAuthOptions struct {
ManageRule *string `form:"manageRule" json:"manageRule"`
@@ -184,3 +202,17 @@ func (o CollectionAuthOptions) Validate() error {
),
)
}
// -------------------------------------------------------------------
// CollectionViewOptions defines the "view" Collection.Options fields.
type CollectionViewOptions struct {
Query string `form:"query" json:"query"`
}
// Validate implements [validation.Validatable] interface.
func (o CollectionViewOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(&o.Query, validation.Required),
)
}
+99 -11
View File
@@ -97,11 +97,11 @@ func TestCollectionMarshalJSON(t *testing.T) {
for _, s := range scenarios {
result, err := s.collection.MarshalJSON()
if err != nil {
t.Errorf("(%s) Unexpected error %v", s.name, err)
t.Errorf("[%s] Unexpected error %v", s.name, err)
continue
}
if string(result) != s.expected {
t.Errorf("(%s) Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
t.Errorf("[%s] Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
}
}
}
@@ -143,7 +143,7 @@ func TestCollectionBaseOptions(t *testing.T) {
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
@@ -188,7 +188,52 @@ func TestCollectionAuthOptions(t *testing.T) {
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
func TestCollectionViewOptions(t *testing.T) {
options := types.JsonMap{"query": "select id from demo1", "minPasswordLength": 4}
expectedSerialization := `{"query":"select id from demo1"}`
scenarios := []struct {
name string
collection models.Collection
expected string
}{
{
"no type",
models.Collection{Options: options},
expectedSerialization,
},
{
"unknown type",
models.Collection{Type: "anything", Options: options},
expectedSerialization,
},
{
"different type",
models.Collection{Type: models.CollectionTypeBase, Options: options},
expectedSerialization,
},
{
"view type",
models.Collection{Type: models.CollectionTypeView, Options: options},
expectedSerialization,
},
}
for _, s := range scenarios {
result := s.collection.ViewOptions()
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
@@ -218,7 +263,7 @@ func TestNormalizeOptions(t *testing.T) {
for _, s := range scenarios {
if err := s.collection.NormalizeOptions(); err != nil {
t.Errorf("(%s) Unexpected error %v", s.name, err)
t.Errorf("[%s] Unexpected error %v", s.name, err)
continue
}
@@ -228,7 +273,7 @@ func TestNormalizeOptions(t *testing.T) {
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
@@ -286,7 +331,7 @@ func TestSetOptions(t *testing.T) {
for _, s := range scenarios {
if err := s.collection.SetOptions(s.options); err != nil {
t.Errorf("(%s) Unexpected error %v", s.name, err)
t.Errorf("[%s] Unexpected error %v", s.name, err)
continue
}
@@ -296,7 +341,7 @@ func TestSetOptions(t *testing.T) {
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
}
}
@@ -378,18 +423,61 @@ func TestCollectionAuthOptionsValidate(t *testing.T) {
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%s) Failed to parse errors %v", s.name, result)
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
if len(errs) != len(s.expectedErrors) {
t.Errorf("(%s) Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
continue
}
for key := range errs {
if !list.ExistInSlice(key, s.expectedErrors) {
t.Errorf("(%s) Unexpected error key %q in \n%v", s.name, key, errs)
t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
}
}
}
}
func TestCollectionViewOptionsValidate(t *testing.T) {
scenarios := []struct {
name string
options models.CollectionViewOptions
expectedErrors []string
}{
{
"empty",
models.CollectionViewOptions{},
[]string{"query"},
},
{
"valid data",
models.CollectionViewOptions{
Query: "test123",
},
[]string{},
},
}
for _, s := range scenarios {
result := s.options.Validate()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
if len(errs) != len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
continue
}
for key := range errs {
if !list.ExistInSlice(key, s.expectedErrors) {
t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
}
}
}
+196 -40
View File
@@ -4,12 +4,15 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"time"
"github.com/pocketbase/dbx"
"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"
@@ -26,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),
}
}
@@ -56,8 +59,11 @@ func nullStringMapValue(data dbx.NullStringMap, key string) any {
// NewRecordFromNullStringMap initializes a single new Record model
// with data loaded from the provided NullStringMap.
//
// Note that this method is intended to load and Scan data from a database
// result and calls PostScan() which marks the record as "not new".
func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
resultMap := map[string]any{}
resultMap := make(map[string]any, len(data))
// load schema fields
for _, field := range collection.Schema.Fields() {
@@ -86,6 +92,9 @@ func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap)
// NewRecordsFromNullStringMaps initializes a new Record model for
// each row in the provided NullStringMap slice.
//
// Note that this method is intended to load and Scan data from a database
// result and calls PostScan() for each record marking them as "not new".
func NewRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) []*Record {
result := make([]*Record, len(rows))
@@ -107,7 +116,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)
@@ -121,15 +131,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
@@ -139,10 +174,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) {
// 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
}
@@ -157,7 +205,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
}
@@ -174,7 +222,7 @@ func (m *Record) MergeExpand(expand map[string]any) {
continue
}
oldIndexed := map[string]*Record{}
oldIndexed := make(map[string]*Record, len(oldSlice))
for _, oldRecord := range oldSlice {
oldIndexed[oldRecord.Id] = oldRecord
}
@@ -190,20 +238,24 @@ func (m *Record) MergeExpand(expand map[string]any) {
}
}
if wasOldSlice || wasNewSlice {
m.expand[key] = oldSlice
if wasOldSlice || wasNewSlice || len(oldSlice) == 0 {
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 := 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
}
}
@@ -215,7 +267,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.
@@ -261,10 +317,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)
}
}
@@ -278,11 +334,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)
}
}
@@ -325,10 +381,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)
}
@@ -366,7 +423,7 @@ func (m *Record) Load(data map[string]any) {
// ColumnValueMap implements [ColumnValueMapper] interface.
func (m *Record) ColumnValueMap() map[string]any {
result := map[string]any{}
result := make(map[string]any, len(m.collection.Schema.Fields())+3)
// export schema field values
for _, field := range m.collection.Schema.Fields() {
@@ -392,7 +449,7 @@ func (m *Record) ColumnValueMap() map[string]any {
//
// Fields marked as hidden will be exported only if `m.IgnoreEmailVisibility(true)` is set.
func (m *Record) PublicExport() map[string]any {
result := map[string]any{}
result := make(map[string]any, len(m.collection.Schema.Fields())+5)
// export unknown data fields if allowed
if m.exportUnknown {
@@ -418,16 +475,20 @@ func (m *Record) PublicExport() map[string]any {
// export base model fields
result[schema.FieldNameId] = m.GetId()
result[schema.FieldNameCreated] = m.GetCreated()
result[schema.FieldNameUpdated] = m.GetUpdated()
if created := m.GetCreated(); !m.Collection().IsView() || !created.IsZero() {
result[schema.FieldNameCreated] = created
}
if updated := m.GetUpdated(); !m.Collection().IsView() || !updated.IsZero() {
result[schema.FieldNameUpdated] = updated
}
// add helper collection reference fields
result[schema.FieldNameCollectionId] = m.collection.Id
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
@@ -453,6 +514,101 @@ func (m *Record) UnmarshalJSON(data []byte) error {
return nil
}
// ReplaceModifers returns a new map with applied modifier
// values based on the current record and the specified data.
//
// The resolved modifier keys will be removed.
//
// Multiple modifiers will be applied one after another,
// while reusing the previous base key value result (eg. 1; -5; +2 => -2).
//
// Example usage:
//
// 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 {
return clone
}
var recordDataCache map[string]any
// export recordData lazily
recordData := func() map[string]any {
if recordDataCache == nil {
recordDataCache = m.SchemaData()
}
return recordDataCache
}
modifiers := schema.FieldValueModifiers()
for _, field := range m.Collection().Schema.Fields() {
key := field.Name
for _, m := range modifiers {
if mv, mOk := clone[key+m]; mOk {
if _, ok := clone[key]; !ok {
// get base value from the merged data
clone[key] = recordData()[key]
}
clone[key] = field.PrepareValueWithModifier(clone[key], m, mv)
delete(clone, key+m)
}
}
if field.Type != schema.FieldTypeFile {
continue
}
// -----------------------------------------------------------
// legacy file field modifiers (kept for backward compatibility)
// -----------------------------------------------------------
var oldNames []string
var toDelete []string
if _, ok := clone[key]; ok {
oldNames = list.ToUniqueStringSlice(clone[key])
} else {
// get oldNames from the model
oldNames = list.ToUniqueStringSlice(recordData()[key])
}
// search for individual file name to delete (eg. "file.test.png = null")
for _, name := range oldNames {
suffixedKey := key + "." + name
if v, ok := clone[suffixedKey]; ok && cast.ToString(v) == "" {
toDelete = append(toDelete, name)
delete(clone, suffixedKey)
continue
}
}
// search for individual file index to delete (eg. "file.0 = null")
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
for indexedKey := range clone {
if keyExp.MatchString(indexedKey) && cast.ToString(clone[indexedKey]) == "" {
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
if indexErr != nil || index < 0 || index >= len(oldNames) {
continue
}
toDelete = append(toDelete, oldNames[index])
delete(clone, indexedKey)
}
}
if toDelete != nil {
clone[key] = field.PrepareValue(list.SubtractSlice(oldNames, toDelete))
}
}
return clone
}
// getNormalizeDataValueForDB returns the "key" data value formatted for db storage.
func (m *Record) getNormalizeDataValueForDB(key string) any {
var val any
@@ -523,7 +679,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
}
+183 -6
View File
@@ -1,6 +1,7 @@
package models_test
import (
"bytes"
"database/sql"
"encoding/json"
"testing"
@@ -138,7 +139,7 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
Valid: true,
},
"field5": sql.NullString{
String: `["test1","test2"]`, // will select only the first elem
String: `["test1","test2"]`, // will select only the last elem
Valid: true,
},
"field6": sql.NullString{
@@ -157,11 +158,11 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
}{
{
models.CollectionTypeBase,
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
},
{
models.CollectionTypeAuth,
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
},
}
@@ -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)
@@ -375,7 +408,7 @@ func TestRecordSetAndGetExpand(t *testing.T) {
}
}
func TestRecordMergeExpandDirect(t *testing.T) {
func TestRecordMergeExpand(t *testing.T) {
collection := &models.Collection{}
m := models.NewRecord(collection)
m.Id = "m"
@@ -467,6 +500,47 @@ func TestRecordMergeExpandDirect(t *testing.T) {
}
}
func TestRecordMergeExpandNilCheck(t *testing.T) {
collection := &models.Collection{}
scenarios := []struct {
name string
expand map[string]any
expected string
}{
{
"nil expand",
nil,
`{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
},
{
"empty expand",
map[string]any{},
`{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
},
{
"non-empty expand",
map[string]any{"test": models.NewRecord(collection)},
`{"collectionId":"","collectionName":"","created":"","expand":{"test":{"collectionId":"","collectionName":"","created":"","id":"","updated":""}},"id":"","updated":""}`,
},
}
for _, s := range scenarios {
m := models.NewRecord(collection)
m.MergeExpand(s.expand)
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
if rawStr != s.expected {
t.Fatalf("[%s] Expected \n%v, \ngot \n%v", s.name, s.expected, rawStr)
}
}
}
func TestRecordSchemaData(t *testing.T) {
collection := &models.Collection{
Type: models.CollectionTypeAuth,
@@ -887,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]`},
@@ -1384,6 +1458,109 @@ func TestRecordUnmarshalJSON(t *testing.T) {
}
}
func TestRecordReplaceModifers(t *testing.T) {
collection := &models.Collection{
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "text",
Type: schema.FieldTypeText,
},
&schema.SchemaField{
Name: "number",
Type: schema.FieldTypeNumber,
},
&schema.SchemaField{
Name: "rel_one",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
},
&schema.SchemaField{
Name: "rel_many",
Type: schema.FieldTypeRelation,
},
&schema.SchemaField{
Name: "select_one",
Type: schema.FieldTypeSelect,
Options: &schema.SelectOptions{MaxSelect: 1},
},
&schema.SchemaField{
Name: "select_many",
Type: schema.FieldTypeSelect,
Options: &schema.SelectOptions{MaxSelect: 10},
},
&schema.SchemaField{
Name: "file_one",
Type: schema.FieldTypeFile,
Options: &schema.FileOptions{MaxSelect: 1},
},
&schema.SchemaField{
Name: "file_one_index",
Type: schema.FieldTypeFile,
Options: &schema.FileOptions{MaxSelect: 1},
},
&schema.SchemaField{
Name: "file_one_name",
Type: schema.FieldTypeFile,
Options: &schema.FileOptions{MaxSelect: 1},
},
&schema.SchemaField{
Name: "file_many",
Type: schema.FieldTypeFile,
Options: &schema.FileOptions{MaxSelect: 10},
},
),
}
record := models.NewRecord(collection)
record.Load(map[string]any{
"text": "test",
"number": 10,
"rel_one": "a",
"rel_many": []string{"a", "b"},
"select_one": "a",
"select_many": []string{"a", "b", "c"},
"file_one": "a",
"file_one_index": "b",
"file_one_name": "c",
"file_many": []string{"a", "b", "c", "d", "e", "f"},
})
result := record.ReplaceModifers(map[string]any{
"text-": "m-",
"text+": "m+",
"number-": 3,
"number+": 5,
"rel_one-": "a",
"rel_one+": "b",
"rel_many-": []string{"a"},
"rel_many+": []string{"c", "d", "e"},
"select_one-": "a",
"select_one+": "c",
"select_many-": []string{"b", "c"},
"select_many+": []string{"d", "e"},
"file_one+": "skip", // should be ignored
"file_one-": "a",
"file_one_index.0": "",
"file_one_name.c": "",
"file_many+": []string{"e", "f"}, // should be ignored
"file_many-": []string{"c", "d"},
"file_many.f": nil,
"file_many.0": nil,
})
raw, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
expected := `{"file_many":["b","e"],"file_one":"","file_one_index":"","file_one_name":"","number":12,"rel_many":["b","c","d","e"],"rel_one":"b","select_many":["a","d","e"],"select_one":"c","text":"test"}`
if v := string(raw); v != expected {
t.Fatalf("Expected \n%s, \ngot \n%s", expected, v)
}
}
// -------------------------------------------------------------------
// Auth helpers:
// -------------------------------------------------------------------
+21
View File
@@ -1,5 +1,11 @@
package models
import (
"strings"
"github.com/pocketbase/pocketbase/models/schema"
)
// RequestData defines a HTTP request data struct, usually used
// as part of the `@request.*` filter resolver.
type RequestData struct {
@@ -9,3 +15,18 @@ type RequestData struct {
AuthRecord *Record `json:"authRecord"`
Admin *Admin `json:"admin"`
}
// HasModifierDataKeys loosely checks if the current struct has any modifier Data keys.
func (r *RequestData) HasModifierDataKeys() bool {
allModifiers := schema.FieldValueModifiers()
for key := range r.Data {
for _, m := range allModifiers {
if strings.HasSuffix(key, m) {
return true
}
}
}
return false
}
+58
View File
@@ -0,0 +1,58 @@
package models_test
import (
"testing"
"github.com/pocketbase/pocketbase/models"
)
func TestRequestDataHasModifierDataKeys(t *testing.T) {
scenarios := []struct {
name string
requestData *models.RequestData
expected bool
}{
{
"empty",
&models.RequestData{},
false,
},
{
"Data with regular fields",
&models.RequestData{
Query: map[string]any{"data+": "demo"}, // should be ignored
Data: map[string]any{"a": 123, "b": "test", "c.d": false},
},
false,
},
{
"Data with +modifier fields",
&models.RequestData{
Data: map[string]any{"a+": 123, "b": "test", "c.d": false},
},
true,
},
{
"Data with -modifier fields",
&models.RequestData{
Data: map[string]any{"a": 123, "b-": "test", "c.d": false},
},
true,
},
{
"Data with mixed modifier fields",
&models.RequestData{
Data: map[string]any{"a": 123, "b-": "test", "c.d+": false},
},
true,
},
}
for _, s := range scenarios {
result := s.requestData.HasModifierDataKeys()
if result != s.expected {
t.Fatalf("[%s] Expected %v, got %v", s.name, s.expected, result)
}
}
}
+144 -25
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"
@@ -15,22 +16,36 @@ import (
var schemaFieldNameRegex = regexp.MustCompile(`^\w+$`)
// field value modifiers
const (
FieldValueModifierAdd string = "+"
FieldValueModifierSubtract string = "-"
)
// FieldValueModifiers returns a list with all available field modifier tokens.
func FieldValueModifiers() []string {
return []string{
FieldValueModifierAdd,
FieldValueModifierSubtract,
}
}
// commonly used field names
const (
FieldNameId = "id"
FieldNameCreated = "created"
FieldNameUpdated = "updated"
FieldNameCollectionId = "collectionId"
FieldNameCollectionName = "collectionName"
FieldNameExpand = "expand"
FieldNameUsername = "username"
FieldNameEmail = "email"
FieldNameEmailVisibility = "emailVisibility"
FieldNameVerified = "verified"
FieldNameTokenKey = "tokenKey"
FieldNamePasswordHash = "passwordHash"
FieldNameLastResetSentAt = "lastResetSentAt"
FieldNameLastVerificationSentAt = "lastVerificationSentAt"
FieldNameId string = "id"
FieldNameCreated string = "created"
FieldNameUpdated string = "updated"
FieldNameCollectionId string = "collectionId"
FieldNameCollectionName string = "collectionName"
FieldNameExpand string = "expand"
FieldNameUsername string = "username"
FieldNameEmail string = "email"
FieldNameEmailVisibility string = "emailVisibility"
FieldNameVerified string = "verified"
FieldNameTokenKey string = "tokenKey"
FieldNamePasswordHash string = "passwordHash"
FieldNameLastResetSentAt string = "lastResetSentAt"
FieldNameLastVerificationSentAt string = "lastVerificationSentAt"
)
// BaseModelFieldNames returns the field names that all models have (id, created, updated).
@@ -72,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"
@@ -90,6 +106,7 @@ func FieldTypes() []string {
FieldTypeBool,
FieldTypeEmail,
FieldTypeUrl,
FieldTypeEditor,
FieldTypeDate,
FieldTypeSelect,
FieldTypeJson,
@@ -122,7 +139,7 @@ type SchemaField struct {
func (f *SchemaField) ColDefinition() string {
switch f.Type {
case FieldTypeNumber:
return "REAL DEFAULT 0"
return "NUMERIC DEFAULT 0"
case FieldTypeBool:
return "BOOLEAN DEFAULT FALSE"
case FieldTypeJson:
@@ -168,7 +185,7 @@ func (f SchemaField) Validate() error {
f.InitOptions()
excludeNames := BaseModelFieldNames()
// exclude filter literals
// exclude special filter literals
excludeNames = append(excludeNames, "null", "true", "false")
// exclude system literals
excludeNames = append(excludeNames, SystemFieldNames()...)
@@ -224,6 +241,8 @@ func (f *SchemaField) InitOptions() error {
options = &EmailOptions{}
case FieldTypeUrl:
options = &UrlOptions{}
case FieldTypeEditor:
options = &EditorOptions{}
case FieldTypeDate:
options = &DateOptions{}
case FieldTypeSelect:
@@ -258,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)
@@ -276,7 +323,7 @@ func (f *SchemaField) PrepareValue(value any) any {
options, _ := f.Options.(*SelectOptions)
if options.MaxSelect <= 1 {
if len(val) > 0 {
return val[0]
return val[len(val)-1] // the last selected
}
return ""
}
@@ -288,7 +335,7 @@ func (f *SchemaField) PrepareValue(value any) any {
options, _ := f.Options.(*FileOptions)
if options.MaxSelect <= 1 {
if len(val) > 0 {
return val[0]
return val[len(val)-1] // the last selected
}
return ""
}
@@ -300,7 +347,7 @@ func (f *SchemaField) PrepareValue(value any) any {
options, _ := f.Options.(*RelationOptions)
if options.MaxSelect != nil && *options.MaxSelect <= 1 {
if len(ids) > 0 {
return ids[0]
return ids[len(ids)-1] // the last selected
}
return ""
}
@@ -311,6 +358,45 @@ func (f *SchemaField) PrepareValue(value any) any {
}
}
// PrepareValueWithModifier returns normalized and properly formatted field value
// by "merging" baseValue with the modifierValue based on the specified modifier (+ or -).
func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, modifierValue any) any {
resolvedValue := baseValue
switch f.Type {
case FieldTypeNumber:
switch modifier {
case FieldValueModifierAdd:
resolvedValue = cast.ToFloat64(baseValue) + cast.ToFloat64(modifierValue)
case FieldValueModifierSubtract:
resolvedValue = cast.ToFloat64(baseValue) - cast.ToFloat64(modifierValue)
}
case FieldTypeSelect, FieldTypeRelation:
switch modifier {
case FieldValueModifierAdd:
resolvedValue = append(
list.ToUniqueStringSlice(baseValue),
list.ToUniqueStringSlice(modifierValue)...,
)
case FieldValueModifierSubtract:
resolvedValue = list.SubtractSlice(
list.ToUniqueStringSlice(baseValue),
list.ToUniqueStringSlice(modifierValue),
)
}
case FieldTypeFile:
// note: file for now supports only the subtract modifier
if modifier == FieldValueModifierSubtract {
resolvedValue = list.SubtractSlice(
list.ToUniqueStringSlice(baseValue),
list.ToUniqueStringSlice(modifierValue),
)
}
}
return f.PrepareValue(resolvedValue)
}
// -------------------------------------------------------------------
// FieldOptions interfaces that defines common methods that every field options struct has.
@@ -419,6 +505,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"`
@@ -501,15 +596,39 @@ 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"`
// MinSelect indicates the min number of allowed relation records
// that could be linked to the main model.
//
// If nil no limits are applied.
MinSelect *int `form:"minSelect" json:"minSelect"`
// 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 {
minVal := 0
if o.MinSelect != nil {
minVal = *o.MinSelect
}
return validation.ValidateStruct(&o,
validation.Field(&o.CollectionId, validation.Required),
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(1)),
validation.Field(&o.MinSelect, validation.NilOrNotEmpty, validation.Min(1)),
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(minVal)),
)
}
+778 -7
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)
@@ -67,7 +67,7 @@ func TestSchemaFieldColDefinition(t *testing.T) {
},
{
schema.SchemaField{Type: schema.FieldTypeNumber, Name: "test"},
"REAL DEFAULT 0",
"NUMERIC DEFAULT 0",
},
{
schema.SchemaField{Type: schema.FieldTypeBool, Name: "test"},
@@ -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,"minSelect":null,"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"},
@@ -603,7 +638,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test1"`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test2"`},
{
// no values validation/filtering
schema.SchemaField{
@@ -680,7 +715,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeFile}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeFile}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test1"`},
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test2"`},
// file (multiple)
{
schema.SchemaField{
@@ -785,7 +820,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
`"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
`"2ba88b4f-e9da-42f0-9764-9a55c953e724"`,
},
// relation (multiple)
{
@@ -863,6 +898,730 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
}
}
func TestSchemaFieldPrepareValueWithModifier(t *testing.T) {
scenarios := []struct {
name string
field schema.SchemaField
baseValue any
modifier string
modifierValue any
expectJson string
}{
// text
{
"text with '+' modifier",
schema.SchemaField{Type: schema.FieldTypeText},
"base",
"+",
"new",
`"base"`,
},
{
"text with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeText},
"base",
"-",
"new",
`"base"`,
},
{
"text with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeText},
"base",
"?",
"new",
`"base"`,
},
{
"text cast check",
schema.SchemaField{Type: schema.FieldTypeText},
123,
"?",
"new",
`"123"`,
},
// number
{
"number with '+' modifier",
schema.SchemaField{Type: schema.FieldTypeNumber},
1,
"+",
4,
`5`,
},
{
"number with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeNumber},
1,
"-",
4,
`-3`,
},
{
"number with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeNumber},
"1",
"?",
4,
`1`,
},
{
"number cast check",
schema.SchemaField{Type: schema.FieldTypeNumber},
"test",
"+",
"4",
`4`,
},
// bool
{
"bool with '+' modifier",
schema.SchemaField{Type: schema.FieldTypeBool},
true,
"+",
false,
`true`,
},
{
"bool with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeBool},
true,
"-",
false,
`true`,
},
{
"bool with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeBool},
true,
"?",
false,
`true`,
},
{
"bool cast check",
schema.SchemaField{Type: schema.FieldTypeBool},
"true",
"?",
false,
`true`,
},
// email
{
"email with '+' modifier",
schema.SchemaField{Type: schema.FieldTypeEmail},
"base",
"+",
"new",
`"base"`,
},
{
"email with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeEmail},
"base",
"-",
"new",
`"base"`,
},
{
"email with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeEmail},
"base",
"?",
"new",
`"base"`,
},
{
"email cast check",
schema.SchemaField{Type: schema.FieldTypeEmail},
123,
"?",
"new",
`"123"`,
},
// url
{
"url with '+' modifier",
schema.SchemaField{Type: schema.FieldTypeUrl},
"base",
"+",
"new",
`"base"`,
},
{
"url with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeUrl},
"base",
"-",
"new",
`"base"`,
},
{
"url with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeUrl},
"base",
"?",
"new",
`"base"`,
},
{
"url cast check",
schema.SchemaField{Type: schema.FieldTypeUrl},
123,
"-",
"new",
`"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",
schema.SchemaField{Type: schema.FieldTypeDate},
"2023-01-01 00:00:00.123",
"+",
"2023-02-01 00:00:00.456",
`"2023-01-01 00:00:00.123Z"`,
},
{
"date with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeDate},
"2023-01-01 00:00:00.123Z",
"-",
"2023-02-01 00:00:00.456Z",
`"2023-01-01 00:00:00.123Z"`,
},
{
"date with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeDate},
"2023-01-01 00:00:00.123",
"?",
"2023-01-01 00:00:00.456",
`"2023-01-01 00:00:00.123Z"`,
},
{
"date cast check",
schema.SchemaField{Type: schema.FieldTypeDate},
1672524000, // 2022-12-31 22:00:00.000Z
"+",
100,
`"2022-12-31 22:00:00.000Z"`,
},
// json
{
"json with '+' modifier",
schema.SchemaField{Type: schema.FieldTypeJson},
10,
"+",
5,
`10`,
},
{
"json with '+' modifier (slice)",
schema.SchemaField{Type: schema.FieldTypeJson},
[]string{"a", "b"},
"+",
"c",
`["a","b"]`,
},
{
"json with '-' modifier",
schema.SchemaField{Type: schema.FieldTypeJson},
10,
"-",
5,
`10`,
},
{
"json with '-' modifier (slice)",
schema.SchemaField{Type: schema.FieldTypeJson},
`["a","b"]`,
"-",
"c",
`["a","b"]`,
},
{
"json with unknown modifier",
schema.SchemaField{Type: schema.FieldTypeJson},
`"base"`,
"?",
`"new"`,
`"base"`,
},
// single select
{
"single select with '+' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"",
"+",
"b",
`"b"`,
},
{
"single select with '+' modifier (nonempty base)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"a",
"+",
"b",
`"b"`,
},
{
"single select with '-' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"",
"-",
"a",
`""`,
},
{
"single select with '-' modifier (nonempty base and empty modifier value)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"a",
"-",
"",
`"a"`,
},
{
"single select with '-' modifier (nonempty base and different value)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"a",
"-",
"b",
`"a"`,
},
{
"single select with '-' modifier (nonempty base and matching value)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"a",
"-",
"a",
`""`,
},
{
"single select with '-' modifier (nonempty base and matching value in a slice)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"a",
"-",
[]string{"b", "a", "c", "123"},
`""`,
},
{
"single select with unknown modifier (nonempty)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
"",
"?",
"a",
`""`,
},
// multi select
{
"multi select with '+' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
nil,
"+",
"b",
`["b"]`,
},
{
"multi select with '+' modifier (nonempty base)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
[]string{"a"},
"+",
[]string{"b", "c"},
`["a","b","c"]`,
},
{
"multi select with '+' modifier (nonempty base; already existing value)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
[]string{"a", "b"},
"+",
"b",
`["a","b"]`,
},
{
"multi select with '-' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
nil,
"-",
[]string{"a"},
`[]`,
},
{
"multi select with '-' modifier (nonempty base and empty modifier value)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
"a",
"-",
"",
`["a"]`,
},
{
"multi select with '-' modifier (nonempty base and different value)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
"a",
"-",
"b",
`["a"]`,
},
{
"multi select with '-' modifier (nonempty base and matching value)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
[]string{"a", "b", "c", "d"},
"-",
"c",
`["a","b","d"]`,
},
{
"multi select with '-' modifier (nonempty base and matching value in a slice)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
[]string{"a", "b", "c", "d"},
"-",
[]string{"b", "a", "123"},
`["c","d"]`,
},
{
"multi select with unknown modifier (nonempty)",
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
[]string{"a", "b"},
"?",
"a",
`["a","b"]`,
},
// single relation
{
"single relation with '+' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"",
"+",
"b",
`"b"`,
},
{
"single relation with '+' modifier (nonempty base)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"a",
"+",
"b",
`"b"`,
},
{
"single relation with '-' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"",
"-",
"a",
`""`,
},
{
"single relation with '-' modifier (nonempty base and empty modifier value)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"a",
"-",
"",
`"a"`,
},
{
"single relation with '-' modifier (nonempty base and different value)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"a",
"-",
"b",
`"a"`,
},
{
"single relation with '-' modifier (nonempty base and matching value)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"a",
"-",
"a",
`""`,
},
{
"single relation with '-' modifier (nonempty base and matching value in a slice)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"a",
"-",
[]string{"b", "a", "c", "123"},
`""`,
},
{
"single relation with unknown modifier (nonempty)",
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
"",
"?",
"a",
`""`,
},
// multi relation
{
"multi relation with '+' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeRelation},
nil,
"+",
"b",
`["b"]`,
},
{
"multi relation with '+' modifier (nonempty base)",
schema.SchemaField{Type: schema.FieldTypeRelation},
[]string{"a"},
"+",
[]string{"b", "c"},
`["a","b","c"]`,
},
{
"multi relation with '+' modifier (nonempty base; already existing value)",
schema.SchemaField{Type: schema.FieldTypeRelation},
[]string{"a", "b"},
"+",
"b",
`["a","b"]`,
},
{
"multi relation with '-' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeRelation},
nil,
"-",
[]string{"a"},
`[]`,
},
{
"multi relation with '-' modifier (nonempty base and empty modifier value)",
schema.SchemaField{Type: schema.FieldTypeRelation},
"a",
"-",
"",
`["a"]`,
},
{
"multi relation with '-' modifier (nonempty base and different value)",
schema.SchemaField{Type: schema.FieldTypeRelation},
"a",
"-",
"b",
`["a"]`,
},
{
"multi relation with '-' modifier (nonempty base and matching value)",
schema.SchemaField{Type: schema.FieldTypeRelation},
[]string{"a", "b", "c", "d"},
"-",
"c",
`["a","b","d"]`,
},
{
"multi relation with '-' modifier (nonempty base and matching value in a slice)",
schema.SchemaField{Type: schema.FieldTypeRelation},
[]string{"a", "b", "c", "d"},
"-",
[]string{"b", "a", "123"},
`["c","d"]`,
},
{
"multi relation with unknown modifier (nonempty)",
schema.SchemaField{Type: schema.FieldTypeRelation},
[]string{"a", "b"},
"?",
"a",
`["a","b"]`,
},
// single file
{
"single file with '+' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"",
"+",
"b",
`""`,
},
{
"single file with '+' modifier (nonempty base)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"a",
"+",
"b",
`"a"`,
},
{
"single file with '-' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"",
"-",
"a",
`""`,
},
{
"single file with '-' modifier (nonempty base and empty modifier value)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"a",
"-",
"",
`"a"`,
},
{
"single file with '-' modifier (nonempty base and different value)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"a",
"-",
"b",
`"a"`,
},
{
"single file with '-' modifier (nonempty base and matching value)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"a",
"-",
"a",
`""`,
},
{
"single file with '-' modifier (nonempty base and matching value in a slice)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"a",
"-",
[]string{"b", "a", "c", "123"},
`""`,
},
{
"single file with unknown modifier (nonempty)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
"",
"?",
"a",
`""`,
},
// multi file
{
"multi file with '+' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
nil,
"+",
"b",
`[]`,
},
{
"multi file with '+' modifier (nonempty base)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
[]string{"a"},
"+",
[]string{"b", "c"},
`["a"]`,
},
{
"multi file with '+' modifier (nonempty base; already existing value)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
[]string{"a", "b"},
"+",
"b",
`["a","b"]`,
},
{
"multi file with '-' modifier (empty base)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
nil,
"-",
[]string{"a"},
`[]`,
},
{
"multi file with '-' modifier (nonempty base and empty modifier value)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
"a",
"-",
"",
`["a"]`,
},
{
"multi file with '-' modifier (nonempty base and different value)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
"a",
"-",
"b",
`["a"]`,
},
{
"multi file with '-' modifier (nonempty base and matching value)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
[]string{"a", "b", "c", "d"},
"-",
"c",
`["a","b","d"]`,
},
{
"multi file with '-' modifier (nonempty base and matching value in a slice)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
[]string{"a", "b", "c", "d"},
"-",
[]string{"b", "a", "123"},
`["c","d"]`,
},
{
"multi file with unknown modifier (nonempty)",
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
[]string{"a", "b"},
"?",
"a",
`["a","b"]`,
},
}
for _, s := range scenarios {
result := s.field.PrepareValueWithModifier(s.baseValue, s.modifier, s.modifierValue)
encoded, err := json.Marshal(result)
if err != nil {
t.Fatalf("[%s] %v", s.name, err)
}
if string(encoded) != s.expectJson {
t.Fatalf("[%s], Expected %v, got %v", s.name, s.expectJson, string(encoded))
}
}
}
// -------------------------------------------------------------------
type fieldOptionsScenario struct {
@@ -1107,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))
@@ -1306,7 +2077,7 @@ func TestRelationOptionsValidate(t *testing.T) {
[]string{"maxSelect"},
},
{
"MaxSelect > 0 && non-empty CollectionId",
"MaxSelect > 0 && nonempty CollectionId",
schema.RelationOptions{
CollectionId: "abc",
MaxSelect: types.Pointer(1),
+69 -18
View File
@@ -15,6 +15,10 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
// SecretMask is the default settings secrets replacement value
// (see Settings.RedactClone()).
const SecretMask string = "******"
// Settings defines common app configuration options.
type Settings struct {
mux sync.RWMutex
@@ -44,6 +48,13 @@ type Settings struct {
SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"`
KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"`
TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
LivechatAuth AuthProviderConfig `form:"livechatAuth" json:"livechatAuth"`
GiteaAuth AuthProviderConfig `form:"giteaAuth" json:"giteaAuth"`
OIDCAuth AuthProviderConfig `form:"oidcAuth" json:"oidcAuth"`
OIDC2Auth AuthProviderConfig `form:"oidc2Auth" json:"oidc2Auth"`
OIDC3Auth AuthProviderConfig `form:"oidc3Auth" json:"oidc3Auth"`
}
// New creates and returns a new default Settings instance.
@@ -124,6 +135,27 @@ func New() *Settings {
TwitchAuth: AuthProviderConfig{
Enabled: false,
},
StravaAuth: AuthProviderConfig{
Enabled: false,
},
GiteeAuth: AuthProviderConfig{
Enabled: false,
},
LivechatAuth: AuthProviderConfig{
Enabled: false,
},
GiteaAuth: AuthProviderConfig{
Enabled: false,
},
OIDCAuth: AuthProviderConfig{
Enabled: false,
},
OIDC2Auth: AuthProviderConfig{
Enabled: false,
},
OIDC3Auth: AuthProviderConfig{
Enabled: false,
},
}
}
@@ -153,6 +185,13 @@ func (s *Settings) Validate() error {
validation.Field(&s.SpotifyAuth),
validation.Field(&s.KakaoAuth),
validation.Field(&s.TwitchAuth),
validation.Field(&s.StravaAuth),
validation.Field(&s.GiteeAuth),
validation.Field(&s.LivechatAuth),
validation.Field(&s.GiteaAuth),
validation.Field(&s.OIDCAuth),
validation.Field(&s.OIDC2Auth),
validation.Field(&s.OIDC3Auth),
)
}
@@ -186,8 +225,6 @@ func (s *Settings) RedactClone() (*Settings, error) {
return nil, err
}
mask := "******"
sensitiveFields := []*string{
&clone.Smtp.Password,
&clone.S3.Secret,
@@ -207,12 +244,19 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.SpotifyAuth.ClientSecret,
&clone.KakaoAuth.ClientSecret,
&clone.TwitchAuth.ClientSecret,
&clone.StravaAuth.ClientSecret,
&clone.GiteeAuth.ClientSecret,
&clone.LivechatAuth.ClientSecret,
&clone.GiteaAuth.ClientSecret,
&clone.OIDCAuth.ClientSecret,
&clone.OIDC2Auth.ClientSecret,
&clone.OIDC3Auth.ClientSecret,
}
// mask all sensitive fields
for _, v := range sensitiveFields {
if v != nil && *v != "" {
*v = mask
*v = SecretMask
}
}
@@ -226,16 +270,23 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
defer s.mux.RUnlock()
return map[string]AuthProviderConfig{
auth.NameGoogle: s.GoogleAuth,
auth.NameFacebook: s.FacebookAuth,
auth.NameGithub: s.GithubAuth,
auth.NameGitlab: s.GitlabAuth,
auth.NameDiscord: s.DiscordAuth,
auth.NameTwitter: s.TwitterAuth,
auth.NameMicrosoft: s.MicrosoftAuth,
auth.NameSpotify: s.SpotifyAuth,
auth.NameKakao: s.KakaoAuth,
auth.NameTwitch: s.TwitchAuth,
auth.NameGoogle: s.GoogleAuth,
auth.NameFacebook: s.FacebookAuth,
auth.NameGithub: s.GithubAuth,
auth.NameGitlab: s.GitlabAuth,
auth.NameDiscord: s.DiscordAuth,
auth.NameTwitter: s.TwitterAuth,
auth.NameMicrosoft: s.MicrosoftAuth,
auth.NameSpotify: s.SpotifyAuth,
auth.NameKakao: s.KakaoAuth,
auth.NameTwitch: s.TwitchAuth,
auth.NameStrava: s.StravaAuth,
auth.NameGitee: s.GiteeAuth,
auth.NameLivechat: s.LivechatAuth,
auth.NameGitea: s.GiteaAuth,
auth.NameOIDC: s.OIDCAuth,
auth.NameOIDC + "2": s.OIDC2Auth,
auth.NameOIDC + "3": s.OIDC3Auth,
}
}
@@ -446,11 +497,11 @@ func (c LogsConfig) Validate() error {
type AuthProviderConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
ClientId string `form:"clientId" json:"clientId,omitempty"`
ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
ClientId string `form:"clientId" json:"clientId"`
ClientSecret string `form:"clientSecret" json:"clientSecret"`
AuthUrl string `form:"authUrl" json:"authUrl"`
TokenUrl string `form:"tokenUrl" json:"tokenUrl"`
UserApiUrl string `form:"userApiUrl" json:"userApiUrl"`
}
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.

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