Compare commits

...
207 Commits
Author SHA1 Message Date
Gani Georgiev c1c499fc1f bumped package version 2025-01-10 08:53:42 +02:00
Gani Georgiev cad16fac6b [#6229] fixed display fields extraction 2025-01-05 14:33:16 +02:00
Gani Georgiev 9414986ca0 updated changelog 2025-01-04 09:24:57 +02:00
Gani Georgiev 6628cdf893 updated tygoja and regenerated jsvm types 2025-01-03 22:11:25 +02:00
Gani Georgiev e521e5343a fixed JSVM types for structs and functions with multiple generic parameters 2025-01-03 21:44:07 +02:00
Gani Georgiev 412341fd78 added missing time macros in the UI autocomplete 2025-01-03 21:38:13 +02:00
Gani Georgiev a8b9aee24e updated otp docs 2025-01-02 08:37:37 +02:00
Gani Georgiev 36df16aaa8 reorganized changelog 2025-01-01 17:49:30 +02:00
Gani Georgiev 44a3e8478d updated go deps and jsvm types 2025-01-01 17:47:17 +02:00
Gani Georgiev 73f1b223ff directly resolve to null for auth check with missing RequestInfo.Auth field 2025-01-01 17:19:30 +02:00
Gani Georgiev c8b29edf9d bumped app version 2025-01-01 16:49:26 +02:00
Gani Georgiev a43f4bf155 reuse the random identifier 2025-01-01 16:41:47 +02:00
Gani Georgiev 3074ed3c5e fixed comment typos 2024-12-30 21:58:29 +02:00
Gani Georgiev 26cb1cef37 added ServeEvent.InstallerFunc field 2024-12-30 20:30:07 +02:00
Gani Georgiev 0155e9333f removed unnecessary request params 2024-12-29 22:29:53 +02:00
Gani Georgiev 118399cc12 fetch colletions list and scaffolds concurrently 2024-12-29 22:26:56 +02:00
Gani Georgiev b062bc6d16 updated jsvm types 2024-12-29 17:46:37 +02:00
Gani Georgiev a8952cfca2 [#6201] expanded the hidden fields check and allow targetting hidden fields in the List API rule 2024-12-29 17:31:58 +02:00
Gani Georgiev 2af9b554ad updated ui/dist 2024-12-28 10:44:51 +02:00
Gani Georgiev 2ef5459698 removed the trailing slash from the env urls 2024-12-28 10:32:14 +02:00
Gani Georgiev 074e977e90 use typed int64 const 2024-12-28 10:23:40 +02:00
Gani Georgiev 07fb052da1 added extra validators for the collection int64 field options 2024-12-28 10:13:18 +02:00
Gani Georgiev 6c53352643 [#6184] fixed unique identity fields input reactivity 2024-12-26 21:37:06 +02:00
Gani Georgiev 00372711fd updated changelog 2024-12-26 13:25:02 +02:00
Gani Georgiev d34c8ec048 added record.SetRandomPassword() helper and updated oauth2 autogenerated password handling 2024-12-26 13:24:03 +02:00
Gani Georgiev d8c0b11271 updated npm deps 2024-12-25 22:37:00 +02:00
Gani Georgiev 56f951e5a2 added crons web apis and ui listing 2024-12-25 22:24:24 +02:00
Gani Georgiev ed1917b307 [#6166] added auth collection select for the settings Send test email popup 2024-12-24 18:10:39 +02:00
Gani Georgiev 47bd4ca11e eagerly interrupt waiting for the email alert send in case it takes longer than 15s 2024-12-24 12:13:33 +02:00
Gani Georgiev 4824701b6c cache jsvm reflect created types 2024-12-24 11:07:26 +02:00
Gani Georgiev 39df26ee21 changed store.Store to accept generic key type 2024-12-23 15:44:00 +02:00
Gani Georgiev e18116d859 synced with master 2024-12-22 16:27:44 +02:00
Gani Georgiev a8dbca64b2 updated changelog 2024-12-22 16:12:36 +02:00
Gani Georgiev 3f25c71780 bumped driver version 2024-12-22 16:10:21 +02:00
Gani Georgiev bae5421d62 added Cron.Jobs() method 2024-12-22 16:05:38 +02:00
Gani Georgiev f27d9f1dc9 synced with master 2024-12-22 10:24:44 +02:00
Gani Georgiev aa52711cde bumped app version 2024-12-20 14:24:40 +02:00
Gani Georgiev 07552c2809 updated modernc versions checks 2024-12-20 14:11:09 +02:00
Gani Georgiev f6407b903b [#6152] skip the default body size limit for the backup endpoint 2024-12-20 13:45:17 +02:00
Gani Georgiev 2ebc6aecac [#6136] added warning logs in case of mismatched modernc deps 2024-12-20 12:26:01 +02:00
Gani Georgiev 884a3dec4a update js sdk 2024-12-19 22:39:22 +02:00
Gani Georgiev cb0335c2b6 normalized negative multipart/form-data numeric inferred value check 2024-12-19 17:23:03 +02:00
Gani Georgiev 274d499279 synced with master 2024-12-19 11:46:35 +02:00
Gani Georgiev a3377c992b fixed code comment typos 2024-12-19 11:41:34 +02:00
Gani Georgiev 7147633f96 bumped golang.org/x/net to 0.33.0 2024-12-19 10:09:05 +02:00
Gani Georgiev c847a6bc88 updated v0.22 changelog 2024-12-18 18:52:34 +02:00
Gani Georgiev 78a35a339f bumped app version 2024-12-18 18:27:42 +02:00
Gani Georgiev c97af83ed1 downgraded modernc,org/libc to 1.55.3 2024-12-18 18:08:03 +02:00
Gani Georgiev 803941705c [#6137] renew the superuser file token cache when clicking on the thumb preview or download link 2024-12-18 16:16:53 +02:00
Gani Georgiev ef0170cf0b [#6132] replaced strconv.Itoa with strconv.FormatInt where it could overflow 2024-12-17 21:41:55 +02:00
Gani Georgiev 76b9051011 [#5964] refresh the token key on email change 2024-12-17 11:44:27 +02:00
Gani Georgiev 0d720c3c9d print inline attachments in the dev mail send 2024-12-17 09:28:26 +02:00
Gani Georgiev 3e5e02a32c synced with master 2024-12-16 16:03:51 +02:00
Gani Georgiev 9b4200a65c updated deps and changelog 2024-12-16 15:04:19 +02:00
Gani Georgiev cb3936a499 [#6122] fixed model->record and model->collection events sync 2024-12-16 14:49:24 +02:00
Gani Georgiev e34c25858c added extra html escaping for the RECORD:* placeholders as extra measure in case the email are stored as plain html 2024-12-16 14:48:44 +02:00
Gani Georgiev fd9ae0fd1c updated js sdk 2024-12-16 10:38:20 +02:00
Gani Georgiev 7ddb0db9a4 added extra html escaping for the RECORD:* placeholders as extra measure in case the email are stored as plain html 2024-12-16 10:36:25 +02:00
Gani Georgiev 011f323bcc updated restore backup log error from debug to warn 2024-12-14 10:40:55 +02:00
Gani Georgiev d5d764f83e avoid unnecessary copying the backup archive on restore when the local file system is used 2024-12-14 10:16:36 +02:00
Gani Georgiev 239daf2023 added mailer.Message.InlineAttachments field 2024-12-13 20:37:24 +02:00
Gani Georgiev 2b2dafaf88 synced with master 2024-12-13 20:26:05 +02:00
Gani Georgiev 3098c2dcd8 [#6102] fixed JSVM exception -> Go error unwrapping 2024-12-13 17:57:14 +02:00
Gani Georgiev 8f32825cff updated jsvm types 2024-12-12 23:02:42 +02:00
Gani Georgiev 20d378cd76 updated collection db methods godoc 2024-12-12 23:01:01 +02:00
Gani Georgiev 8e63e81561 speedup records cascade delete 2024-12-12 22:55:55 +02:00
Gani Georgiev efe4ef500b removed the create api rule tooltip 2024-12-12 20:25:44 +02:00
Gani Georgiev 09a24e1de6 synced with master 2024-12-12 12:17:02 +02:00
Gani Georgiev 9490a220bc moved unnecessary reassignment 2024-12-12 12:11:47 +02:00
Gani Georgiev 4f35fb74c8 updated jsvm types 2024-12-11 19:00:45 +02:00
Gani Georgiev f533320722 updated go deps and bumped app version 2024-12-11 19:00:28 +02:00
Gani Georgiev e51456bce2 [#6073] added poc implementation for the dry submit removal 2024-12-11 18:43:48 +02:00
Gani Georgiev 35196674e6 skip unnecessary validator in case the default pattern is used 2024-12-11 18:27:21 +02:00
Gani Georgiev 7481c3f7f4 replaced LOWER with COLLATE NOCASE 2024-12-11 11:08:29 +02:00
Gani Georgiev 3ec10a9c7d fixed log message typo 2024-12-11 11:07:42 +02:00
Gani Georgiev 9efd68ff4c [#6066] updated text field validation message 2024-12-09 12:24:12 +02:00
Gani Georgiev 3634fd9c26 synced with master 2024-12-09 08:25:44 +02:00
Gani Georgiev 9747f46c1d [#6063] fixed x-forwarded-for typo 2024-12-09 04:39:14 +02:00
Gani Georgiev 88a1867169 [#6058] fixed filesystem.fileFromURL documentation and generated type 2024-12-08 17:51:30 +02:00
Gani Georgiev f7c85940c4 synced with master 2024-12-08 14:43:07 +02:00
Gani Georgiev 545a4eb47c updated ui/dist 2024-12-08 14:00:07 +02:00
Gani Georgiev 5f660d8671 updated go deps and min go release action version 2024-12-08 13:52:10 +02:00
Gani Georgiev 85c31ba068 [#6053] fixed text field max validation error message 2024-12-08 13:23:18 +02:00
Gani Georgiev 6edb344ab3 synced with upstream s3 driver 2024-12-07 12:15:03 +02:00
Gani Georgiev c91d889da3 udpated :lower modifier to apply after all other normalizations 2024-12-06 22:09:29 +02:00
Gani Georgiev 6a4e04533c added tests.NewTestAppWithConfig helper 2024-12-06 21:38:57 +02:00
Gani Georgiev e8f49c31e4 added :lower modifier 2024-12-06 16:10:11 +02:00
Gani Georgiev 1abd6ca5c0 Merge branch 'master' into develop 2024-12-06 14:21:37 +02:00
Gani Georgiev 6f55695fa1 updated jsvm types 2024-12-06 14:21:22 +02:00
Gani Georgiev 7c01441392 added yesterday and tomorrow date filter macros 2024-12-05 13:34:11 +02:00
Gani Georgiev c6695b6a75 updated min go version and removed legacy CGO note from the readme 2024-12-04 11:12:10 +02:00
Gani Georgiev b0276ad605 [#6019] fixed README example 2024-12-04 11:06:00 +02:00
Gani Georgiev 376627b4cc fixed logs search 2024-12-03 12:39:27 +02:00
Gani Georgiev 0661de0604 updated go deps 2024-12-02 14:04:11 +02:00
Gani Georgiev f80de83234 updated ui/dist 2024-12-02 14:03:16 +02:00
Gani Georgiev 2d828ef9eb added more descriptive test OTP id and failure log message 2024-12-02 13:46:29 +02:00
Gani Georgiev 77ac44a49a moved the default UI CSP as response header 2024-12-02 13:45:44 +02:00
Gani Georgiev fb2763a697 updated autodate test interceptors 2024-12-02 12:51:25 +02:00
Gani Georgiev 5835a51111 [#6000] fixed autodate interceptors 2024-12-02 12:39:02 +02:00
Gani Georgiev 3c2d43d37b updated npm deps 2024-11-29 11:59:33 +02:00
Gani Georgiev 6ee25cbe12 minimized repeated field.GetName calls 2024-11-29 11:30:54 +02:00
Gani Georgiev 67e6be8073 updated ui/dist 2024-11-29 11:19:15 +02:00
Gani Georgiev d69e81922f updated Record.Fresh and Record.Clone tests 2024-11-29 11:10:27 +02:00
Gani Georgiev 51ac522e7f [#5973] fixed Record.Fresh and Record.Clone method not properly copying the record fields 2024-11-28 23:19:21 +02:00
Gani Georgiev bcb2dca44e updated go deps 2024-11-28 16:24:21 +02:00
Gani Georgiev 79f6f4ee60 fixed comment typo 2024-11-28 15:21:19 +02:00
Gani Georgiev ab7194a639 fixed gzip middleware not applying when serving static files 2024-11-28 13:51:43 +02:00
Gani Georgiev d92016af81 updated changelog 2024-11-27 10:02:11 +02:00
Gani Georgiev 6b9d1b559c updated ui/dist 2024-11-26 22:34:23 +02:00
Gani Georgiev 44f097f7d2 updated jsvm types 2024-11-26 22:31:46 +02:00
Gani Georgiev fcf65dcc77 added collection rules change list in the confirmation popup 2024-11-26 22:28:34 +02:00
Gani Georgiev 06acaf38d8 fixed dev log query print formatting 2024-11-26 20:58:35 +02:00
Gani Georgiev cb2b27f6ed updated otp request error message 2024-11-26 19:51:21 +02:00
Gani Georgiev 6e26cb5d88 [#5958] fixed RecordQuery() custom struct scanning 2024-11-26 14:15:39 +02:00
Gani Georgiev f1b199b35c added support for passing more than one id in the Hook.Unbind method for consistency with the router 2024-11-26 11:52:26 +02:00
Gani Georgiev 0ac4a388c0 updated changelog 2024-11-25 11:39:26 +02:00
Gani Georgiev aff8abccc7 updated changelog 2024-11-25 11:36:07 +02:00
Gani Georgiev 51dfbc251a updated jsvm types 2024-11-25 11:30:59 +02:00
Gani Georgiev c933190db8 updated ui/dist 2024-11-25 11:30:01 +02:00
Gani Georgiev fab334fca6 updated error messages 2024-11-25 11:28:20 +02:00
Gani Georgiev 5a5211d7f2 [#5940] added blob response write helper 2024-11-25 09:11:58 +02:00
Gani Georgiev 8f6e91c485 added note about the trailing slash cahnge in the changelog 2024-11-24 22:10:24 +02:00
Gani Georgiev 64fa4b9cef updated changelog link 2024-11-24 21:14:54 +02:00
Gani Georgiev d5dddf3ead updated PocketBase JS SDK version 2024-11-24 16:37:01 +02:00
Gani Georgiev 62a4795a4c exclude autogenerated changelog htmls from git 2024-11-24 16:20:34 +02:00
Gani Georgiev fd18eb520f updated go deps 2024-11-24 16:14:51 +02:00
Gani Georgiev 73361370f0 added list hidden field filter test 2024-11-24 15:35:42 +02:00
Gani Georgiev ef71daae65 autofocus the installer email field 2024-11-24 15:02:48 +02:00
Gani Georgiev 5936fc3ac3 add modified fields with order in the generated migration 2024-11-24 15:02:28 +02:00
Gani Georgiev 0efca0f936 updated ui/dist and changelog 2024-11-24 12:48:03 +02:00
Gani Georgiev 1e92b51cf7 added option to insert/move fields at specific position 2024-11-24 12:41:57 +02:00
Gani Georgiev e9ece220d6 added env variables support for the thumbs generation limits 2024-11-23 20:38:30 +02:00
Gani Georgiev 2dd4e38e1d fixed superuser OTP id input reactivity, enabled truncate dropdown option for system collections, updated jsvm types 2024-11-23 14:12:18 +02:00
Gani Georgiev b34453afc7 show the OTP id in the superuser form 2024-11-23 12:44:03 +02:00
Gani Georgiev c3b347af4b added note and tests regarding the shared batch Authorization header 2024-11-23 11:54:25 +02:00
Gani Georgiev 68e9c55925 updated checkOldPassword validator 2024-11-23 10:28:31 +02:00
Gani Georgiev e5800875c2 updated ui/dist and added fallback debug log 2024-11-22 23:30:44 +02:00
Gani Georgiev 2e43518bb4 synced ported cors middleware 2024-11-22 23:19:23 +02:00
Gani Georgiev e5f1bc3c37 renamed Monday.FetchRawUserData to Monday.FetchUserInfo 2024-11-22 22:43:16 +02:00
Gani Georgievandchnfyi e3bf81cb79 [#5909] added Linear OAuth2 provider
Co-authored-by: chnfyi <143424481+chnfyi@users.noreply.github.com>
2024-11-22 22:42:04 +02:00
Gani Georgiev 305e183f58 added apis.ToApiError alias 2024-11-22 20:44:29 +02:00
Gani Georgiev 7795a086ef hide batch api preview for view collections 2024-11-22 16:18:52 +02:00
Gani Georgiev 5d8a8dd7d8 updated godoc and renamed cors middleware handler 2024-11-21 22:22:58 +02:00
Gani Georgiev 31d3c27f43 add s390x and ppc64le linux builds 2024-11-21 16:55:27 +02:00
Gani Georgiev c3ac28a8e6 updated go.mod 2024-11-21 16:33:40 +02:00
Gani Georgiev ab26941d3f enabled Buffer goja_nodejs module 2024-11-21 16:29:42 +02:00
Gani Georgiev a18df14f4f updated js sdk and other npm deps 2024-11-21 12:24:04 +02:00
Gani Georgiev c2e7ab8d41 fixed oauth2 redirect test 2024-11-21 12:11:00 +02:00
Gani Georgiev 7ee6b11e9d return an error in case of required MFA so that external handlers can react if necessary 2024-11-21 11:12:25 +02:00
Gani Georgiev 8ab02ce402 updared rate limiter sort rules 2024-11-21 11:11:51 +02:00
Gani Georgiev 779f6c1a74 added record json copy option 2024-11-20 21:04:42 +02:00
Gani Georgiev 34d7ac0808 close channel on client discard 2024-11-19 23:23:25 +02:00
Gani Georgiev d0795bd849 updated tests 2024-11-19 22:37:44 +02:00
Gani Georgiev 08f2190ad1 [#5898] instead of unregister, unset the realtime client auth state on delete of the related auth record 2024-11-19 22:36:32 +02:00
Gani Georgiev d919d55b5e allow mixing existing file names and new uploaded files 2024-11-19 17:45:15 +02:00
Gani Georgiev 52c64318c5 fixed comment typo 2024-11-19 17:27:18 +02:00
Gani Georgiev 9fe4f87e5b added required validator for the TextField.Pattern option in case it is a primary key 2024-11-19 17:21:43 +02:00
Gani Georgiev 48328bf33f added extra validations in case of id pattern validator misuse 2024-11-19 15:59:59 +02:00
Gani Georgiev 52e85a8036 added oauth2 db errors handling and replaced the auth response map with a struct 2024-11-18 21:16:20 +02:00
Gani Georgiev 37538f2a6d auto sort rate limit rules 2024-11-18 16:07:52 +02:00
Gani Georgiev 70df03ffbb fixed rate limiter rules matching to acount for the Audience field 2024-11-18 14:46:06 +02:00
Gani Georgiev 487e83c84e fixed typo and cleanup destroyed overlay wrappers 2024-11-18 08:23:39 +02:00
Gani Georgiev 14aaeb06fc fixed responsive styles of the OAuth2 providers list 2024-11-18 07:51:19 +02:00
Gani Georgiev 19ea9cca36 removed old system created and updated fields from the record preview 2024-11-18 05:56:29 +02:00
Gani Georgiev 6090b29070 [#5887] fixed record duplicate and removed the duplicated id field in the record preview 2024-11-18 05:53:20 +02:00
Gani Georgiev 957ba6266d updated changelogs 2024-11-17 15:54:23 +02:00
Gani Georgiev e1472d922a updated ui/dist 2024-11-17 15:38:38 +02:00
Gani Georgiev 3653e70a6d added periodic wal_checkpoint calls 2024-11-17 14:26:42 +02:00
Gani Georgiev 7f2fcc0046 added manual WAL checkpoints before creating the zip backup to avoid copying unnecessary data 2024-11-17 12:33:23 +02:00
Gani Georgiev 32e0eadc2f updated godoc links 2024-11-16 18:57:00 +02:00
Gani Georgiev 18a7549e50 updated collection indexes on system fields validator and normalized v0.23 old collections migration 2024-11-15 09:13:00 +02:00
Gani Georgiev e53c30ca4d added 'table is locked' error msg retry check 2024-11-15 07:45:27 +02:00
Gani Georgiev 4adc4b28d2 updated changelog 2024-11-14 17:19:27 +02:00
Gani Georgiev c5554e22e0 refresh the old collections in the UI import on successful import submission 2024-11-14 16:48:41 +02:00
Gani Georgiev 846136dcfb restored mfa ErrNoRows check 2024-11-13 20:24:52 +02:00
Gani Georgiev bc378d33f6 updated go deps 2024-11-13 20:15:15 +02:00
Gani Georgiev cc833ad643 updated mfa defaults and errors check 2024-11-13 20:14:27 +02:00
Gani Georgiev 396aa0f97c always load the full record on record view 2024-11-13 19:55:33 +02:00
Gani Georgiev 9f606bdeca otp changes - added sentTo field, allow e.Record to be nil when requesting OTP, etc. 2024-11-13 18:34:43 +02:00
Gani Georgiev 10a5c685ab removed the dynamic dashboard path option as it could complicate unnecessary too many things (oauth2 redirects, default email templates, etc.) 2024-11-12 12:32:26 +02:00
Gani Georgiev 1ca90e5e8b fixed typo 2024-11-11 16:24:46 +02:00
Gani Georgiev db57572a54 lowered the max field id and name length limit to 100 2024-11-11 16:18:24 +02:00
Gani Georgiev 0af8f3cc66 updated go deps 2024-11-11 16:04:40 +02:00
Gani Georgievandtigawanna 438ecd88a0 [#5829] added WakaTime OAuth2 provider
Co-authored-by: tigawanna <denniskinuthiawaweru@gmail.com>
2024-11-11 16:03:46 +02:00
Gani Georgiev 45628a919f added search filter and sort limits 2024-11-11 14:58:43 +02:00
Gani Georgiev fc133d8665 fixed settings test and error typo 2024-11-11 14:25:21 +02:00
Gani Georgiev 5e6d4d2126 added rate limit helpers for future use 2024-11-11 14:24:54 +02:00
Gani Georgiev c38e7c36a6 added throttling on too many failed search attempts 2024-11-11 14:24:38 +02:00
Gani Georgiev a89960db71 aligned struct fields 2024-11-11 00:04:22 +02:00
Gani Georgiev 7119ff4716 renamed local field var 2024-11-11 00:03:25 +02:00
Gani Georgiev 339399b0a4 updated otp manual rate limiter 2024-11-09 12:24:46 +02:00
Gani Georgiev 9cb6adab4d added superuser otp command 2024-11-08 18:04:22 +02:00
Gani Georgiev f6aef4471d added RateLimitRule.Audience field 2024-11-08 18:04:13 +02:00
Gani Georgiev 0e56521e8a fixed rc10 migration 2024-11-08 09:43:57 +02:00
Gani Georgiev 4f8f320280 updated changelog 2024-11-07 14:15:56 +02:00
Gani GeorgievandPedro Costa 0daa584461 [#5588] replaced deprecated Instagram Basic Display provider with a new Instagram Login
Co-authored-by: Pedro Costa <550684+pnmcosta@users.noreply.github.com>
2024-11-07 13:51:21 +02:00
Gani Georgiev 57f615a58c added default users collection rules 2024-11-07 13:24:08 +02:00
Gani Georgiev 1bd0baf328 updated v0.22.x changelog 2024-11-07 13:00:29 +02:00
Gani Georgiev 6694215909 updated changelog 2024-11-06 20:25:34 +02:00
Gani Georgiev 241a81e1fc updated installer note 2024-11-06 20:06:34 +02:00
Gani Georgiev bed45beb13 updated comments and pass the dashboard path into the installer 2024-11-06 19:19:16 +02:00
Gani Georgiev e4cd6810ab always register the installer hooks in case the superuser is created by a console command 2024-11-06 14:23:16 +02:00
Gani Georgiev f38700982c removed RequestEvent.UnsafeRealIP 2024-11-05 21:49:45 +02:00
Gani Georgiev 9506669095 refactored installer and removed RequireSuperuserAuthOnlyIfAny 2024-11-05 21:12:17 +02:00
Gani Georgiev 4f67dba6cb [#5800] skip default loadAuthToken middleware if e.Auth is already set 2024-11-05 09:08:52 +02:00
Gani Georgiev 47354f5aa9 updated jsvm types 2024-11-05 00:16:20 +02:00
Gani Georgiev 2f35ef29fe [#5797] fixed JSVM types errors 2024-11-05 00:06:48 +02:00
268 changed files with 13232 additions and 8325 deletions

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '>=1.23.0'
go-version: '>=1.23.4'
# 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.
+2
View File
@@ -15,4 +15,6 @@ coverage.out
# generated markdown previews
README.html
CHANGELOG.html
CHANGELOG_16_22.html
CHANGELOG_8_15.html
LICENSE.html
+10
View File
@@ -24,13 +24,23 @@ builds:
- amd64
- arm64
- arm
- s390x
- ppc64le
goarm:
- 7
ignore:
- goos: windows
goarch: arm
- goos: windows
goarch: s390x
- goos: windows
goarch: ppc64le
- goos: darwin
goarch: arm
- goos: darwin
goarch: s390x
- goos: darwin
goarch: ppc64le
release:
draft: true
+108 -187
View File
@@ -1,277 +1,192 @@
## v0.23.0-rc10
## v0.24.2
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
- Restore the CRC32 checksum autogeneration for the collection/field ids in order to maintain deterministic default identifier value and minimize conflicts between custom migrations and full collections snapshots.
_There is a system migration that will attempt to normalize existing system collections ids, but if you already migrated to v0.23.0-rc and have generated a full collections snapshot migration, you have to delete it and regenerate a new one._
- Change the behavior of the default generated collections snapshot migration to act as "extend" instead of "replace" to prevent accidental data deletion.
_I think this would be rare but if you want the old behaviour you can edit the generated snapshot file and replace the second argument (`deleteMissing`) of `App.ImportCollection/App.ImportCollectionsByMarshaledJSON` from `false` to `true`._
## v0.24.1
- Added `app.SubscriptionsBroker().TotalClients()` helper method to return the total registered realtime clients ([#5793](https://github.com/pocketbase/pocketbase/issues/5793)).
- Added missing time macros in the UI autocomplete.
- Fixed JSVM types for structs and functions with multiple generic parameters.
## v0.23.0-rc9
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
## v0.24.0
- Fixed auto `www.` redirect due to missing URI schema.
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records
(_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_).
As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)).
- Fixed collection and field renaming when reusing an old collection/field name ([#5741](https://github.com/pocketbase/pocketbase/issues/5741)).
- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types.
For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`.
- Update the "API preview" section to include information about the batch api.
- Added `@yesterday` and `@tomorrow` datetime filter macros.
- Exported `core.DefaultDBConnect` function that could be used as a fallback when initializing custom SQLite drivers and builds.
- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
- ⚠️ No longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition ...` linker errors in case different CGO SQLite drivers or builds are used.
This means that no matter of the `CGO_ENABLED` value, now out of the box PocketBase will always use only the pure Go driver ([`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite)).
This will be documented properly in the new website but if you want to continue using `mattn/go-sqlite3` (e.g. because of the icu or other builtin extension) you could register it as follow:
```go
package main
- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
import (
"database/sql"
"log"
- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
"github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
)
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
func init() {
// initialize default PRAGMAs for each new connection
sql.Register("pb_sqlite3",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
_, err := conn.Exec(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -16000;
`, nil)
- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
_The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
return err
},
},
)
- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
}
- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("pb_sqlite3", dbPath)
},
})
- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
// custom hooks and plugins...
- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
Also note that if you are not planning to use the `core.DefaultDBConnect` fallback as part of your custom driver registration you can exclude the default pure Go driver from the build with the build tag `-tags no_default_driver` to reduce the binary size a little.
- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
- ⚠️ Removed JSVM `BaseCollection()`, `AuthCollection()`, `ViewCollection()` class aliases for simplicity and to avoid confusion with the accepted constructor arguments (_you can simply use as before `new Collection({ type: "base", ... })`; this will also initialize the default type specific options_).
- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
- Other minor improvements (added validator for duplicated index definitions, updated the impersonate popup styles, added query param support for loading a collection based on its name, etc.).
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
## v0.23.0-rc8
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Lock the `_otps` and `_mfas` system collections Delete API rule for superusers only.
## v0.23.12
- Reassign in the JSVM executors the global `$app` variable with the hook scoped `e.app` value to minimize the risk of a deadlock when a hook or middleware is wrapped in a transaction.
- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)).
- Reuse the OAuth2 created user record pointer to ensure that all its following hooks operate on the same record instance.
- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
- Added tags support for the `OnFileTokenRequest` hook.
- Other minor changes (added index for the `_collections` type column, added more detailed godoc for the collection fields and `core.App` methods, fixed flaky record enrich tests, etc.).
## v0.23.11
- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
## v0.23.0-rc7
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
## v0.23.10
- Register the default panic-recover middleware after the activity logger so that we can log the error.
- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
- Updated the `RequestEvent.BindBody` FormData type inferring rules to convert numeric strings into float64 only if the resulting minimal number string representation matches the initial FormData string value ([#5687](https://github.com/pocketbase/pocketbase/issues/5687)).
- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
- Fixed the JSVM types to include properly generated function declarations when the related Go functions have shortened/combined return values.
- Reorganized the record table fields<->columns syncing to remove the `PRAGMA writable_schema` usage.
## v0.23.9
- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)).
## v0.23.0-rc6
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
## v0.23.8
- Fixed realtime 403 API error on resubscribe ([#5674](https://github.com/pocketbase/pocketbase/issues/5674)).
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
- Fixed the auto OAuth2 avatar mapped field assignment when the OAuth2 provider doesn't return an avatar URL ([#5673](https://github.com/pocketbase/pocketbase/pull/5673)).
_In case the avatar retrieval fails and the mapped record field "Required" option is not set, the error is silenced and only logged with WARN level._
- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.).
- Added `Router.SEARCH(path, action)` helper method for registering `SEARCH` endpoints.
- Changed all builtin middlewares to return `*hook.Handler[*core.RequestEvent]` with a default middleware id for consistency and to allow removal.
Or in other words, replace `.BindFunc(apis.Gzip())` with `.Bind(apis.Gzip())`.
## v0.23.7
- Updated the JSVM types to reflect the recent changes.
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
## v0.23.0-rc5
## v0.23.6
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)).
- Added Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)).
- Added monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
- Added option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
- Fixed the relation record picker to sort by default by `@rowid` instead of the `created` field as the latter is optional ([#5641](https://github.com/pocketbase/pocketbase/discussions/5641)).
- Fixed the UI "Set Superusers only" button click not properly resetting the input state.
## v0.23.5
- Fixed the OAuth2 providers logo path shown in the "Authorized providers" UI.
- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used.
- Fixed the single value UI for the `select`, `file` and `relation` fields ([#5646](https://github.com/pocketbase/pocketbase/discussions/5646))
- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)).
- Other minor fixes (comment typos, JSVM types update).
## v0.23.0-rc4
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Fixed the UI settings update form to prevent sending empty string for the mail password or the S3 secret options on resave of the form.
## v0.23.4
- ⚠️ Added an exception for the `OAuth2` field in the GO->JSVM name mapping rules:
```
// old -> new
collection.oAuth2.* -> collection.oauth2.*
```
- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
- Added more user friendly view collection truncate error message.
- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
- Added an extra suffix character to the name of autogenerated template migration file for `*test` suffixed collections to prevent acidentally resulting in `_test.go` migration files.
- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
- Added `FieldsList.AddMarshaledJSON([]byte)` helper method to load a serialized json array of objects or a single json object into an existing collection fields list.
- Updated Go and npm dependencies.
- Fixed the autogenerated Go migration template when updating a collection ([#5631](https://github.com/pocketbase/pocketbase/discussions/5631)).
⚠️ If you have already used a previous prerelease and have autogenerated Go migration files, please check the migration files named **`{timestamp}_updated_{collection}.go`** and manually change:
## v0.23.3
<table width="100%">
<tr>
<th width="50%">Old (broken)</th>
<th width="50%">New</th>
</tr>
<tr>
<td width="50%">
- Fixed Gzip middleware not applying when serving static files.
```go
// add field / update field
if err := json.Unmarshal([]byte(`[{
...
}]`), &collection.Fields); err != nil {
return err
}
```
- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)).
</td>
<td width="50%">
```go
// add field / update field
if err := collection.Fields.AddMarshaledJSON([]byte(`{
...
}`)); err != nil {
return err
}
```
## v0.23.2
</td>
</tr>
</table>
To test that your Go migration files work correctly you can try to start PocketBase with a new temp pb_data, e.g.:
- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)).
```go
go run . serve --dir="pb_data_temp"
```
- Fixed `--dev` log query print formatting.
- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
## v0.23.0-rc3
- Added collection rules change list in the confirmation popup
(_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_).
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Make `PRAGMA optimize` statement optional in case it is not supported by the driver ([#5611](https://github.com/pocketbase/pocketbase/discussions/5611)).
## v0.23.1
- Reapply the minimum required `pb_data/auxiliary.db` migrations if the db file was manually deleted ([#5618](https://github.com/pocketbase/pocketbase/discussions/5618)).
- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)).
- To avoid confusion and unnecessary casting, the `hook.HandlerFunc[T]` type has been removed and instead everywhere we now use directly the underlying function definition, aka.:
```go
func(T) error
```
- Added more descriptive error messages.
- Fixed the UI input field type of the OTP.length field ([#5617](https://github.com/pocketbase/pocketbase/issues/5617)).
- Other minor fixes (fixed API preview and examples error message typos, better hint for combined/multi-spaced view query columns, fixed the path for the HTTPS green favicon path, etc.).
## v0.23.0-rc2
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Small update to the earlier v0.23.0-rc that uses `pb_data/auxiliary.db` instead of `pb_data/aux.db` because it seems that on Windows `aux` is disallowed as file name ([#5607](https://github.com/pocketbase/pocketbase/issues/5607)).
_If you have already upgraded to v0.23.0-rc please rename manually your `pb_data/aux.db` file to `pb_data/auxiliary.db`._
## v0.23.0-rc
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
>
> It introduces many Go/JSVM breaking changes and requires manual migration steps.
>
> All new features will be reflected in the new website documentation with the final v0.23.0 release.
## v0.23.0
> [!NOTE]
> Please note that you don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
> You don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
> your existing app and/or are satisfied with the v0.22.x features set. There are no identified critical issues
> with PocketBase v0.22.x yet and in the case of critical bugs and security vulnerabilities, the fixes
> will be backported for at least until Q1 of 2025 (_if not longer_).
>
> If you don't plan upgrading just make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:
> **If you don't plan upgrading make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:**
> - JS SDK: `<0.22.0`
> - Dart SDK: `<0.19.0`
PocketBase v0.23.0-rc is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
> [!CAUTION]
> This release introduces many Go/JSVM and Web APIs breaking changes!
>
> Existing `pb_data` will be automatically upgraded with the start of the new executable,
> but custom Go or JSVM (`pb_hooks`, `pb_migrations`) and JS/Dart SDK code will have to be migrated manually.
> Please refer to the below upgrade guides:
> - Go: https://pocketbase.io/v023upgrade/go/.
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
>
> If you had already switched to some of the earlier `<v0.23.0-rc14` versions and have generated a full collections snapshot migration (aka. `./pocketbase migrate collections`), then you may have to regenerate the migration file to ensure that it includes the latest changes.
There are many changes but to highlight some of the most notable ones:
PocketBase v0.23.0 is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
There are a lot of changes but to highlight some of the most notable ones:
- New and more [detailed documentation](https://pocketbase.io/docs/).
_The old documentation could be accessed at [pocketbase.io/old](https://pocketbase.io/old/)._
- Replaced `echo` with a new router built on top of the Go 1.22 `net/http` mux enhancements.
- Merged `daos` packages in `core.App` to simplify the DB operations (_the `models` package structs are also migrated in `core`_).
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
- Option to mark field as Private/Hidden, disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
- Admins are now system `_superusers` auth records.
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
@@ -281,23 +196,25 @@ There are many changes but to highlight some of the most notable ones:
- One-Time Password (OTP) auth method (_via email code_).
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
- Support for Record "proxy/projection" in preparation for the planned autogeneration of typed Go record models.
- Linear OAuth2 provider ([#5909](https://github.com/pocketbase/pocketbase/pull/5909); thanks @chnfyi).
- WakaTime OAuth2 provider ([#5829](https://github.com/pocketbase/pocketbase/pull/5829); thanks @tigawanna).
- Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
- monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
- New Instagram provider compatible with the new Instagram Login APIs ([#5588](https://github.com/pocketbase/pocketbase/pull/5588); thanks @pnmcosta).
_The provider key is `instagram2` to prevent conflicts with existing linked users._
- Option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
- Various minor UI improvements (_recursive `Presentable` view, slightly different collection options organization, zoom/pan for the logs chart, etc._)
- and many more...
In terms of performance, the Go standard router mux is known to be slightly slower compared to Gin, Echo, etc. implementations, but based on my local tests the difference is negliable.
The [benchmarks repo](https://github.com/pocketbase/benchmarks) will be updated with the final v0.23.0 release (_currently there seems to be ~10% memory consumption increase which I'll have to investigate to see whether it is from the router change or from the new hooks_).
#### Go/JSVM APIs changes
For upgrading to PocketBase v0.23.0, please refer to:
- Go: https://pocketbase.io/v023upgrade/go/.
- JSVM: https://pocketbase.io/v023upgrade/jsvm/.
> - Go: https://pocketbase.io/v023upgrade/go/.
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
#### SDKs changes
- [JS SDK v0.22.0-rc](https://github.com/pocketbase/js-sdk/blob/develop/CHANGELOG.md)
- [Dart SDK v0.19.0-rc](https://github.com/pocketbase/dart-sdk/blob/develop/CHANGELOG.md)
- [JS SDK v0.22.0](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
- [Dart SDK v0.19.0](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
#### Web APIs changes
@@ -313,6 +230,10 @@ For upgrading to PocketBase v0.23.0, please refer to:
- New `POST /api/collections/{collection}/impersonate/{id}` endpoint.
- ⚠️ If you are constructing requests to `/api/*` routes manually remove the trailing slash (_there is no longer trailing slash removal middleware registered by default_).
- ⚠️ Removed `/api/admins/*` endpoints because admins are converted to `_superusers` auth collection records.
- ⚠️ Previously when uploading new files to a multiple `file` field, new files were automatically appended to the existing field values.
This behaviour has changed with v0.23+ and for consistency with the other multi-valued fields when uploading new files they will replace the old ones. If you want to prepend or append new files to an existing multiple `file` field value you can use the `+` prefix or suffix:
```js
+46 -1
View File
@@ -2,6 +2,51 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.29
- (_Backported from v0.23.11_) Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
## v0.22.28
- (_Backported from v0.23.10_) Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
- (_Backported from v0.23.10_) Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
## v0.22.27
- Instead of unregistering the realtime clients, we now just unset their auth state on delete of the related auth record so that the clients can receive the `delete` event ([#5898](https://github.com/pocketbase/pocketbase/issues/5898)).
## v0.22.26
- (_Backported from v0.23.0-rc_) Added manual WAL checkpoints before creating the zip backup to minimize copying unnecessary data.
## v0.22.25
- Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)).
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibity of eventual side-channel attacks.
## v0.22.24
- Delete new uploaded record files in case of DB persist error ([#5845](https://github.com/pocketbase/pocketbase/issues/5845)).
## v0.22.23
- Updated the hooks watcher to account for the case when hooksDir is a symlink ([#5789](https://github.com/pocketbase/pocketbase/issues/5789)).
- _(Backported from v0.23.0-rc)_ Registered a default `http.Server.ErrorLog` handler to report general server connection errors as app Debug level logs (e.g. invalid TLS handshakes caused by bots trying to access your server via its IP or other similar errors).
- Other minor fixes (updated npm dev deps to fix the vulnerabilities warning, added more user friendly realtime topic length error, regenerated JSVM types, etc.)
## v0.22.22
- Added deprecation log in case Instagram OAuth2 is used (_related to [#5652](https://github.com/pocketbase/pocketbase/discussions/5652)_).
@@ -1112,7 +1157,7 @@
- Updated the schema fields Admin UI for "tidier" fields visualization.
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forwarded-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
+2 -9
View File
@@ -54,7 +54,6 @@ Here is a minimal example:
import (
"log"
"net/http"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
@@ -65,7 +64,7 @@ Here is a minimal example:
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// registers new "GET /hello" route
se.Router.Get("/hello", func(re *core.RequestEvent) error {
se.Router.GET("/hello", func(re *core.RequestEvent) error {
return re.String(200, "Hello world!")
})
@@ -84,19 +83,13 @@ Here is a minimal example:
4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
> [!NOTE]
> PocketBase embeds SQLite, but doesn't require CGO.
>
> 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.
_For more details please refer to [Extend with Go](https://pocketbase.io/docs/go-overview/)._
### Building and running the repo main.go example
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.21+](https://go.dev/doc/install) (_if you haven't already_)
0. [Install Go 1.23+](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`
+5
View File
@@ -6,6 +6,11 @@ import "github.com/pocketbase/pocketbase/tools/router"
// and for consistency with the JSVM binds.
// -------------------------------------------------------------------
// ToApiError wraps err into ApiError instance (if not already).
func ToApiError(err error) *router.ApiError {
return router.ToApiError(err)
}
// NewApiError is an alias for [router.NewApiError].
func NewApiError(status int, message string, errData any) *router.ApiError {
return router.NewApiError(status, message, errData)
+2 -2
View File
@@ -18,10 +18,10 @@ func bindBackupApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
sub := rg.Group("/backups")
sub.GET("", backupsList).Bind(RequireSuperuserAuth())
sub.POST("", backupCreate).Bind(RequireSuperuserAuth())
sub.POST("/upload", backupUpload).Bind(RequireSuperuserAuthOnlyIfAny())
sub.POST("/upload", backupUpload).Bind(BodyLimit(0), RequireSuperuserAuth())
sub.GET("/{key}", backupDownload) // relies on superuser file token
sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuthOnlyIfAny())
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
}
type backupFileInfo struct {
+8 -37
View File
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"gocloud.dev/blob"
@@ -347,28 +348,17 @@ func TestBackupUpload(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "unauthorized with 0 superusers (valid file)",
Name: "ensure that the default body limit is skipped",
Method: http.MethodPost,
URL: "/api/backups/upload",
Body: bodies[5].buffer,
Body: bytes.NewBuffer(make([]byte, apis.DefaultMaxBodySize+100)),
Headers: map[string]string{
"Content-Type": bodies[5].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
}
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"*": 0},
ExpectedStatus: 400, // it doesn't matter as long as it is not 413
ExpectedContent: []string{`"data":{`},
NotExpectedContent: []string{"entity too large"},
ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -780,25 +770,6 @@ func TestBackupsRestore(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "unauthorized with no superusers (checks only access)",
Method: http.MethodPost,
URL: "/api/backups/missing.zip/restore",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
+1
View File
@@ -41,6 +41,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindRecordAuthApi(app, apiGroup)
bindLogsApi(app, apiGroup)
bindBackupApi(app, apiGroup)
bindCronApi(app, apiGroup)
bindFileApi(app, apiGroup)
bindBatchApi(app, apiGroup)
bindRealtimeApi(app, apiGroup)
+6
View File
@@ -338,6 +338,12 @@ func processInternalRequest(
// apply batch request specific headers
// ---
for k, v := range ir.Headers {
// individual Authorization header keys don't have affect
// because the auth state is populated from the base event
if strings.EqualFold(k, "authorization") {
continue
}
r.Header.Set(k, v)
}
r.Header.Set("Content-Type", mw.FormDataContentType())
+3 -3
View File
@@ -294,9 +294,9 @@ func TestBatchRequest(t *testing.T) {
},
Body: strings.NewReader(`{
"requests": [
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}},
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3"},
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}}
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}, "headers": {"Authorization": "ignored"}},
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}}
]
}`),
ExpectedStatus: 200,
+1 -1
View File
@@ -724,7 +724,7 @@ func TestCollectionCreate(t *testing.T) {
ExpectedContent: []string{
`"name":"new"`,
`"type":"view"`,
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
},
ExpectedEvents: map[string]int{
"*": 0,
+59
View File
@@ -0,0 +1,59 @@
package apis
import (
"net/http"
"slices"
"strings"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/routine"
)
// bindCronApi registers the crons api endpoint.
func bindCronApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup := rg.Group("/crons").Bind(RequireSuperuserAuth())
subGroup.GET("", cronsList)
subGroup.POST("/{id}", cronRun)
}
func cronsList(e *core.RequestEvent) error {
jobs := e.App.Cron().Jobs()
slices.SortStableFunc(jobs, func(a, b *cron.Job) int {
if strings.HasPrefix(a.Id(), "__pb") {
return 1
}
if strings.HasPrefix(b.Id(), "__pb") {
return -1
}
return strings.Compare(a.Id(), b.Id())
})
return e.JSON(http.StatusOK, jobs)
}
func cronRun(e *core.RequestEvent) error {
cronId := e.Request.PathValue("id")
var foundJob *cron.Job
jobs := e.App.Cron().Jobs()
for _, j := range jobs {
if j.Id() == cronId {
foundJob = j
break
}
}
if foundJob == nil {
return e.NotFoundError("Missing or invalid cron job", nil)
}
routine.FireAndForget(func() {
foundJob.Run()
})
return e.NoContent(http.StatusNoContent)
}
+149
View File
@@ -0,0 +1,149 @@
package apis_test
import (
"net/http"
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/spf13/cast"
)
func TestCronsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
URL: "/api/crons",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodGet,
URL: "/api/crons",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser (empty list)",
Method: http.MethodGet,
URL: "/api/crons",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Cron().RemoveAll()
},
ExpectedStatus: 200,
ExpectedContent: []string{`[]`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser",
Method: http.MethodGet,
URL: "/api/crons",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`{"id":"__pbLogsCleanup__","expression":"0 */6 * * *"}`,
`{"id":"__pbDBOptimize__","expression":"0 0 * * *"}`,
`{"id":"__pbMFACleanup__","expression":"0 * * * *"}`,
`{"id":"__pbOTPCleanup__","expression":"0 * * * *"}`,
},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestCronsRun(t *testing.T) {
t.Parallel()
beforeTestFunc := func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Cron().Add("test", "* * * * *", func() {
app.Store().Set("testJobCalls", cast.ToInt(app.Store().Get("testJobCalls"))+1)
})
}
expectedCalls := func(expected int) func(t testing.TB, app *tests.TestApp, res *http.Response) {
return func(t testing.TB, app *tests.TestApp, res *http.Response) {
total := cast.ToInt(app.Store().Get("testJobCalls"))
if total != expected {
t.Fatalf("Expected total testJobCalls %d, got %d", expected, total)
}
}
}
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
URL: "/api/crons/test",
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodPost,
URL: "/api/crons/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0),
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser (missing job)",
Method: http.MethodPost,
URL: "/api/crons/missing",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0),
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser (existing job)",
Method: http.MethodPost,
URL: "/api/crons/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(1),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
-137
View File
@@ -1,137 +0,0 @@
package apis
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
const installerParam = "pbinstal"
var wildcardPlaceholderRegex = regexp.MustCompile(`/{.+\.\.\.}$`)
func stripWildcard(pattern string) string {
return wildcardPlaceholderRegex.ReplaceAllString(pattern, "")
}
// installerRedirect redirects the user to the installer dashboard UI page
// when the application needs some preliminary configurations to be done.
func installerRedirect(app core.App, cpPath string) func(*core.RequestEvent) error {
// note: to avoid locks contention it is not concurrent safe but it
// is expected to be updated only once during initialization
var hasSuperuser bool
// strip named wildcard
cpPath = stripWildcard(cpPath)
updateHasSuperuser := func(app core.App) error {
total, err := app.CountRecords(core.CollectionNameSuperusers)
if err != nil {
return err
}
hasSuperuser = total > 0
return nil
}
// load initial state on app init
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
err := e.Next()
if err != nil {
return err
}
err = updateHasSuperuser(e.App)
if err != nil {
return fmt.Errorf("failed to check for existing superuser: %w", err)
}
return nil
})
// update on superuser create
app.OnRecordCreateRequest(core.CollectionNameSuperusers).BindFunc(func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
if !hasSuperuser {
hasSuperuser = true
}
return nil
})
return func(e *core.RequestEvent) error {
if hasSuperuser {
return e.Next()
}
isAPI := strings.HasPrefix(e.Request.URL.Path, "/api/")
isControlPanel := strings.HasPrefix(e.Request.URL.Path, cpPath)
wildcard := e.Request.PathValue(StaticWildcardParam)
// skip redirect checks for API and non-root level dashboard index.html requests (css, images, etc.)
if isAPI || (isControlPanel && wildcard != "" && wildcard != router.IndexPage) {
return e.Next()
}
// check again in case the superuser was created by some other process
if err := updateHasSuperuser(e.App); err != nil {
return err
}
if hasSuperuser {
return e.Next()
}
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
// redirect to the installer page
if !hasInstallerParam {
return e.Redirect(http.StatusTemporaryRedirect, cpPath+"?"+installerParam+"#")
}
return e.Next()
}
}
// dashboardRemoveInstallerParam redirects to a non-installer
// query param in case there is already a superuser created.
//
// Note: intended to be registered only for the dashboard route
// to prevent excessive checks for every other route in installerRedirect.
func dashboardRemoveInstallerParam() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
if !hasInstallerParam {
return e.Next() // nothing to remove
}
// clear installer param
total, _ := e.App.CountRecords(core.CollectionNameSuperusers)
if total > 0 {
return e.Redirect(http.StatusTemporaryRedirect, "?")
}
return e.Next()
}
}
// dashboardCacheControl adds default Cache-Control header for all
// dashboard UI resources (ignoring the root index.html path)
func dashboardCacheControl() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
if e.Request.PathValue(StaticWildcardParam) != "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
return e.Next()
}
}
+14 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"runtime"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/spf13/cast"
"golang.org/x/sync/semaphore"
"golang.org/x/sync/singleflight"
)
@@ -22,10 +24,20 @@ var defaultThumbSizes = []string{"100x100"}
// bindFileApi registers the file api endpoints and the corresponding handlers.
func bindFileApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
maxWorkers := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WORKERS"))
if maxWorkers <= 0 {
maxWorkers = int64(runtime.NumCPU() + 2) // the value is arbitrary chosen and may change in the future
}
maxWait := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WAIT"))
if maxWait <= 0 {
maxWait = 60
}
api := fileApi{
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 2)), // the value is arbitrary chosen and may change in the future
thumbGenPending: new(singleflight.Group),
thumbGenMaxWait: 60 * time.Second,
thumbGenSem: semaphore.NewWeighted(maxWorkers),
thumbGenMaxWait: time.Duration(maxWait) * time.Second,
}
sub := rg.Group("/files")
+88
View File
@@ -0,0 +1,88 @@
package apis
import (
"database/sql"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils"
)
// DefaultInstallerFunc is the default PocketBase installer function.
//
// It will attempt to open a link in the browser (with a short-lived auth
// token for the systemSuperuser) to the installer UI so that users can
// create their own custom superuser record.
//
// See https://github.com/pocketbase/pocketbase/discussions/5814.
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
if err != nil {
return err
}
// launch url (ignore errors and always print a help text as fallback)
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
_ = osutils.LaunchURL(url)
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
color.New(color.Bold).Add(color.FgCyan).Println(url)
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0])
return nil
}
func loadInstaller(
app core.App,
baseURL string,
installerFunc func(app core.App, systemSuperuser *core.Record, baseURL string) error,
) error {
if installerFunc == nil || !needInstallerSuperuser(app) {
return nil
}
superuser, err := findOrCreateInstallerSuperuser(app)
if err != nil {
return err
}
return installerFunc(app, superuser, baseURL)
}
func needInstallerSuperuser(app core.App) bool {
total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
"email": core.DefaultInstallerEmail,
}))
return err == nil && total == 0
}
func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
col, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return nil, err
}
record, err := app.FindAuthRecordByEmail(col, core.DefaultInstallerEmail)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
record = core.NewRecord(col)
record.SetEmail(core.DefaultInstallerEmail)
record.SetRandomPassword()
err = app.Save(record)
if err != nil {
return nil, err
}
}
return record, nil
}
+9 -36
View File
@@ -48,7 +48,6 @@ const (
DefaultRequireGuestOnlyMiddlewareId = "pbRequireGuestOnly"
DefaultRequireAuthMiddlewareId = "pbRequireAuth"
DefaultRequireSuperuserAuthMiddlewareId = "pbRequireSuperuserAuth"
DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId = "pbRequireSuperuserAuthOnlyIfAny"
DefaultRequireSuperuserOrOwnerAuthMiddlewareId = "pbRequireSuperuserOrOwnerAuth"
DefaultRequireSameCollectionContextAuthMiddlewareId = "pbRequireSameCollectionContextAuth"
)
@@ -110,31 +109,6 @@ func RequireSuperuserAuth() *hook.Handler[*core.RequestEvent] {
}
}
// RequireSuperuserAuthOnlyIfAny middleware requires a request to have
// a valid superuser Authorization header ONLY if the application has
// at least 1 existing superuser.
func RequireSuperuserAuthOnlyIfAny() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId,
Func: func(e *core.RequestEvent) error {
if e.HasSuperuserAuth() {
return e.Next()
}
totalSuperusers, err := e.App.CountRecords(core.CollectionNameSuperusers)
if err != nil {
return e.InternalServerError("Failed to fetch superusers info.", err)
}
if totalSuperusers == 0 {
return e.Next()
}
return requireAuth(core.CollectionNameSuperusers)(e)
},
}
}
// RequireSuperuserOrOwnerAuth middleware requires a request to have
// a valid superuser or regular record owner Authorization header set.
//
@@ -196,7 +170,9 @@ func RequireSameCollectionContextAuth(collectionPathParam string) *hook.Handler[
// loadAuthToken attempts to load the auth context based on the "Authorization: TOKEN" header value.
//
// This middleware does nothing in case of missing, invalid or expired token.
// This middleware does nothing in case of:
// - missing, invalid or expired token
// - e.Auth is already loaded by another middleware
//
// This middleware is registered by default for all routes.
//
@@ -207,6 +183,11 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
Id: DefaultLoadAuthTokenMiddlewareId,
Priority: DefaultLoadAuthTokenMiddlewarePriority,
Func: func(e *core.RequestEvent) error {
// already loaded by another middleware
if e.Auth != nil {
return e.Next()
}
token := getAuthTokenFromRequest(e)
if token == "" {
return e.Next()
@@ -416,17 +397,9 @@ func logRequest(event *core.RequestEvent, err error) {
}
if event.App.Settings().Logs.LogIP {
var userIP string
if len(event.App.Settings().TrustedProxy.Headers) > 0 {
userIP = event.RealIP()
} else {
// fallback to the legacy behavior (it is "safe" since it is only for log purposes)
userIP = cutStr(event.UnsafeRealIP(), 50)
}
attrs = append(
attrs,
slog.String("userIP", userIP),
slog.String("userIP", event.RealIP()),
slog.String("remoteIP", event.RemoteIP()),
)
}
+1 -1
View File
@@ -18,7 +18,7 @@ const (
DefaultBodyLimitMiddlewarePriority = DefaultRateLimitMiddlewarePriority + 10
)
// BodyLimit returns a middleware function that changes the default request body size limit.
// BodyLimit returns a middleware handler that changes the default request body size limit.
//
// If limitBytes <= 0, no limit is applied.
//
+21 -5
View File
@@ -13,6 +13,7 @@ package apis
// -------------------------------------------------------------------
import (
"log"
"net/http"
"regexp"
"strconv"
@@ -124,8 +125,8 @@ var DefaultCORSConfig = CORSConfig{
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}
// CORSWithConfig returns a CORS middleware with config.
func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
// CORS returns a CORS middleware.
func CORS(config CORSConfig) *hook.Handler[*core.RequestEvent] {
// Defaults
if len(config.AllowOrigins) == 0 {
config.AllowOrigins = DefaultCORSConfig.AllowOrigins
@@ -134,13 +135,26 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
config.AllowMethods = DefaultCORSConfig.AllowMethods
}
allowOriginPatterns := []string{}
allowOriginPatterns := make([]*regexp.Regexp, 0, len(config.AllowOrigins))
for _, origin := range config.AllowOrigins {
if origin == "*" {
continue // "*" is handled differently and does not need regexp
}
pattern := regexp.QuoteMeta(origin)
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
pattern = strings.ReplaceAll(pattern, "\\?", ".")
pattern = "^" + pattern + "$"
allowOriginPatterns = append(allowOriginPatterns, pattern)
re, err := regexp.Compile(pattern)
if err != nil {
// This is to preserve previous behaviour - invalid patterns were just ignored.
// If we would turn this to panic, users with invalid patterns
// would have applications crashing in production due unrecovered panic.
log.Println("invalid AllowOrigins pattern", origin)
continue
}
allowOriginPatterns = append(allowOriginPatterns, re)
}
allowMethods := strings.Join(config.AllowMethods, ",")
@@ -210,7 +224,7 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
}
if checkPatterns {
for _, re := range allowOriginPatterns {
if match, _ := regexp.MatchString(re, origin); match {
if match := re.MatchString(origin); match {
allowOrigin = origin
break
}
@@ -272,6 +286,7 @@ func matchSubdomain(domain, pattern string) bool {
if !matchScheme(domain, pattern) {
return false
}
didx := strings.Index(domain, "://")
pidx := strings.Index(pattern, "://")
if didx == -1 || pidx == -1 {
@@ -307,5 +322,6 @@ func matchSubdomain(domain, pattern string) bool {
return false
}
}
return false
}
+20 -17
View File
@@ -221,23 +221,26 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
}
}
func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
if w.wroteHeader {
w.ResponseWriter.WriteHeader(w.code)
}
rw := w.ResponseWriter
for {
switch rf := rw.(type) {
case io.ReaderFrom:
return rf.ReadFrom(r)
case router.RWUnwrapper:
rw = rf.Unwrap()
default:
return io.Copy(w.ResponseWriter, r)
}
}
}
// Note: Disable the implementation for now because in case the platform
// supports the sendfile fast-path it won't run gzipResponseWriter.Write,
// preventing compression on the fly.
//
// func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
// if w.wroteHeader {
// w.ResponseWriter.WriteHeader(w.code)
// }
// rw := w.ResponseWriter
// for {
// switch rf := rw.(type) {
// case io.ReaderFrom:
// return rf.ReadFrom(r)
// case router.RWUnwrapper:
// rw = rf.Unwrap()
// default:
// return io.Copy(w.ResponseWriter, r)
// }
// }
// }
func (w *gzipResponseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
+67 -9
View File
@@ -32,9 +32,12 @@ func rateLimit() *hook.Handler[*core.RequestEvent] {
return e.Next()
}
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(defaultRateLimitLabels(e))
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(
defaultRateLimitLabels(e),
defaultRateLimitAudience(e)...,
)
if ok {
err := checkRateLimit(e, e.Request.Pattern, rule)
err := checkRateLimit(e, rule.Label+rule.Audience, rule)
if err != nil {
return err
}
@@ -94,9 +97,9 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
}
labels = append(labels, defaultRateLimitLabels(e)...)
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels)
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels, defaultRateLimitAudience(e)...)
if ok {
return checkRateLimit(e, rtId, rule)
return checkRateLimit(e, rtId+rule.Audience, rule)
}
return nil
@@ -104,11 +107,46 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
// -------------------------------------------------------------------
// @todo consider exporting as RateLimit helper?
// @todo consider exporting as helper?
//
//nolint:unused
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
if !ok || rateLimiters == nil {
return false
}
rt, ok := rateLimiters.GetOk(rtId)
if !ok || rt == nil {
return false
}
client, ok := rt.getClient(e.RealIP())
if !ok || client == nil {
return false
}
return client.available <= 0 && time.Now().Unix()-client.lastConsume < client.interval
}
// @todo consider exporting as helper?
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
switch rule.Audience {
case core.RateLimitRuleAudienceAll:
// valid for both guest and regular users
case core.RateLimitRuleAudienceGuest:
if e.Auth != nil {
return nil
}
case core.RateLimitRuleAudienceAuth:
if e.Auth == nil {
return nil
}
}
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
return initRateLimitersStore(e.App)
}).(*store.Store[*rateLimiter])
}).(*store.Store[string, *rateLimiter])
if rateLimiters == nil {
e.App.Logger().Warn("Failed to retrieve app rate limiters store")
return nil
@@ -139,6 +177,17 @@ func skipRateLimit(e *core.RequestEvent) bool {
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
}
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
var defaultGuestAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceGuest}
func defaultRateLimitAudience(e *core.RequestEvent) []string {
if e.Auth != nil {
return defaultAuthAudience
}
return defaultGuestAudience
}
func defaultRateLimitLabels(e *core.RequestEvent) []string {
return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
}
@@ -149,9 +198,9 @@ func destroyRateLimitersStore(app core.App) {
app.Store().Remove(rateLimitersStoreKey)
}
func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
func initRateLimitersStore(app core.App) *store.Store[string, *rateLimiter] {
app.Cron().Add(rateLimitersCronKey, "2 * * * *", func() { // offset a little since too many cleanup tasks execute at 00
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[*rateLimiter])
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
if !ok {
return
}
@@ -176,7 +225,7 @@ func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
},
})
return store.New[*rateLimiter](nil)
return store.New[string, *rateLimiter](nil)
}
func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec int64) *rateLimiter {
@@ -199,6 +248,15 @@ type rateLimiter struct {
sync.RWMutex
}
//nolint:unused
func (rt *rateLimiter) getClient(key string) (*fixedWindow, bool) {
rt.RLock()
client, ok := rt.clients[key]
rt.RUnlock()
return client, ok
}
func (rt *rateLimiter) isAllowed(key string) bool {
// lock only reads to minimize locks contention
rt.RLock()
+76 -20
View File
@@ -31,6 +31,18 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
MaxRequests: 1,
Duration: 1,
},
{
Label: "/rate/guest",
MaxRequests: 1,
Duration: 1,
Audience: core.RateLimitRuleAudienceGuest,
},
{
Label: "/rate/auth",
MaxRequests: 1,
Duration: 1,
Audience: core.RateLimitRuleAudienceAuth,
},
}
pbRouter, err := apis.NewRouter(app)
@@ -48,6 +60,12 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
pbRouter.GET("/rate/b", func(e *core.RequestEvent) error {
return e.String(200, "b")
})
pbRouter.GET("/rate/guest", func(e *core.RequestEvent) error {
return e.String(200, "guest")
})
pbRouter.GET("/rate/auth", func(e *core.RequestEvent) error {
return e.String(200, "auth")
})
mux, err := pbRouter.BuildMux()
if err != nil {
@@ -57,30 +75,53 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
scenarios := []struct {
url string
wait float64
authenticated bool
expectedStatus int
}{
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/rate/a", 0, 200},
{"/rate/a", 0, 200},
{"/rate/a", 0, 429},
{"/rate/a", 0, 429},
{"/rate/a", 1.1, 200},
{"/rate/a", 0, 200},
{"/rate/a", 0, 429},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
{"/rate/a", 0, false, 429},
{"/rate/a", 1.1, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
{"/rate/b", 0, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 429},
{"/rate/b", 1.1, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 429},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429},
{"/rate/b", 1.1, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429},
// "auth" with guest (should fallback to the /rate/ rule)
{"/rate/auth", 0, false, 200},
{"/rate/auth", 0, false, 200},
{"/rate/auth", 0, false, 429},
{"/rate/auth", 0, false, 429},
// "auth" rule with regular user (should match the /rate/auth rule)
{"/rate/auth", 0, true, 200},
{"/rate/auth", 0, true, 429},
{"/rate/auth", 0, true, 429},
// "guest" with guest (should match the /rate/guest rule)
{"/rate/guest", 0, false, 200},
{"/rate/guest", 0, false, 429},
{"/rate/guest", 0, false, 429},
// "guest" rule with regular user (should fallback to the /rate/ rule)
{"/rate/guest", 1, true, 200},
{"/rate/guest", 0, true, 200},
{"/rate/guest", 0, true, 429},
{"/rate/guest", 0, true, 429},
}
for _, s := range scenarios {
@@ -91,6 +132,21 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", s.url, nil)
if s.authenticated {
auth, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
token, err := auth.NewAuthToken()
if err != nil {
t.Fatal(err)
}
req.Header.Add("Authorization", token)
}
mux.ServeHTTP(rec, req)
result := rec.Result()
-89
View File
@@ -302,95 +302,6 @@ func TestRequireSuperuserAuth(t *testing.T) {
}
}
func TestRequireSuperuserAuthOnlyIfAny(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest (while having at least 1 existing superuser)",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "guest (while having 0 existing superusers)",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid regular user token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid superuser auth token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
t.Parallel()
+4 -4
View File
@@ -242,8 +242,8 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
return group.Wait()
}
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
@@ -255,7 +255,7 @@ func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
if clientAuth != nil &&
clientAuth.Id == authModel.PK() &&
clientAuth.Collection().Name == authModel.TableName() {
app.SubscriptionsBroker().Unregister(client.Id())
client.Unset(RealtimeClientAuthKey)
}
}
@@ -293,7 +293,7 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelEvent) error {
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil && collection.IsAuth() {
if err := realtimeUnregisterClientsByAuth(e.App, e.Model); err != nil {
if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth model",
slog.Any("id", e.Model.PK()),
+64 -14
View File
@@ -427,26 +427,51 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
// init realtime handlers
apis.NewRouter(testApp)
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.RealtimeClientAuthKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client1)
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client2)
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
testApp.SubscriptionsBroker().Register(client3)
// mock delete event
e := new(core.ModelEvent)
e.App = testApp
e.Type = core.ModelEventTypeDelete
e.Context = context.Background()
e.Model = authRecord
e.Model = authRecord1
testApp.OnModelAfterDeleteSuccess().Trigger(e)
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
t.Fatalf("Expected no subscription clients, found %d", total)
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
}
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
}
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
}
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
}
}
@@ -524,17 +549,30 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
// init realtime handlers
apis.NewRouter(testApp)
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.RealtimeClientAuthKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client1)
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client2)
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
testApp.SubscriptionsBroker().Register(client3)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp, "test@example.com")
customUser, err := findCustomUserByEmail(testApp, authRecord1.Email())
if err != nil {
t.Fatal(err)
}
@@ -544,8 +582,20 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
t.Fatal(err)
}
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
t.Fatalf("Expected no subscription clients, found %d", total)
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
}
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
}
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
}
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
}
}
+2 -3
View File
@@ -38,9 +38,8 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
event.NewEmail = newEmail
return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
authRecord.Set(core.FieldNameEmail, e.NewEmail)
authRecord.Set(core.FieldNameVerified, true)
authRecord.RefreshTokenKey() // invalidate old tokens
e.Record.SetEmail(e.NewEmail)
e.Record.SetVerified(true)
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
+18 -9
View File
@@ -1,6 +1,7 @@
package apis
import (
"database/sql"
"errors"
"fmt"
"net/http"
@@ -32,12 +33,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
}
record, err := e.App.FindAuthRecordByEmail(collection, form.Email)
if err != nil {
// eagerly write a dummy 200 response as a very rudimentary user emails enumeration protection
e.JSON(http.StatusOK, map[string]string{
"otpId": core.GenerateDefaultRandomId(),
})
return fmt.Errorf("failed to fetch %s record with email %s: %w", collection.Name, form.Email, err)
// ignore not found errors to allow custom record find implementations
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return e.InternalServerError("", err)
}
event := new(core.RecordCreateOTPRequestEvent)
@@ -46,7 +45,18 @@ func recordRequestOTP(e *core.RequestEvent) error {
event.Collection = collection
event.Record = record
originalApp := e.App
return e.App.OnRecordRequestOTPRequest().Trigger(event, func(e *core.RecordCreateOTPRequestEvent) error {
if e.Record == nil {
// write a dummy 200 response as a very rudimentary emails enumeration "protection"
e.JSON(http.StatusOK, map[string]string{
"otpId": core.GenerateDefaultRandomId(),
})
return fmt.Errorf("missing or invalid %s OTP auth record with email %s", collection.Name, form.Email)
}
var otp *core.OTP
// limit the new OTP creations for a single user
@@ -90,11 +100,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
// send OTP email
// (in the background as a very basic timing attacks and emails enumeration protection)
// ---
app := e.App
routine.FireAndForget(func() {
err = mails.SendRecordOTP(app, e.Record, otp.Id, e.Password)
err = mails.SendRecordOTP(originalApp, e.Record, otp.Id, e.Password)
if err != nil {
app.Logger().Error("Failed to send OTP email", "error", errors.Join(err, e.App.Delete(otp)))
originalApp.Logger().Error("Failed to send OTP email", "error", errors.Join(err, originalApp.Delete(otp)))
}
})
}
+63 -3
View File
@@ -86,7 +86,10 @@ func TestRecordRequestOTP(t *testing.T) {
ExpectedContent: []string{
`"otpId":"`, // some fake random generated string
},
ExpectedEvents: map[string]int{"*": 0},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordRequestOTPRequest": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 0 {
t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
@@ -137,6 +140,57 @@ func TestRecordRequestOTP(t *testing.T) {
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 2, // + 1 for the OTP update after the email send
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 2,
// OTP update
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
}
// ensure that sentTo is set
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
if err != nil || len(otps) != 1 {
t.Fatalf("Expected to find 1 OTP with sentTo %q, found %d", "test@example.com", len(otps))
}
},
},
{
Name: "existing auth record with intercepted email (with < 9 non-expired)",
Method: http.MethodPost,
URL: "/api/collections/users/request-otp",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// prevent email sent
app.OnMailerRecordOTPSend("users").BindFunc(func(e *core.MailerRecordEvent) error {
return nil
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"otpId":"`,
},
NotExpectedContent: []string{
`"otpId":"otp_`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordRequestOTPRequest": 1,
"OnMailerRecordOTPSend": 1,
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
@@ -144,8 +198,14 @@ func TestRecordRequestOTP(t *testing.T) {
"OnRecordValidate": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
if app.TestMailer.TotalSend() != 0 {
t.Fatalf("Expected 0 emails, got %d", app.TestMailer.TotalSend())
}
// ensure that there is no OTP with user email as sentTo
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
if err != nil || len(otps) != 0 {
t.Fatalf("Expected to find 0 OTPs with sentTo %q, found %d", "test@example.com", len(otps))
}
},
},
+2 -2
View File
@@ -47,12 +47,12 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
}
}
err = form.app.Save(authRecord)
err = e.App.Save(authRecord)
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to set new password.", err))
}
form.app.Store().Remove(getPasswordResetResendKey(authRecord))
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
return e.NoContent(http.StatusNoContent)
})
@@ -186,11 +186,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
t.Fatal("Expected the user to be unverified")
}
oldTokenKey := user.TokenKey()
// manually change the email to check whether the verified state will be updated
user.SetEmail("test_update@example.com")
if err := app.Save(user); err != nil {
if err = app.Save(user); err != nil {
t.Fatalf("Failed to update user test email: %v", err)
}
// resave with the old token key since the email change above
// would change it and will make the password token invalid
user.SetTokenKey(oldTokenKey)
if err = app.Save(user); err != nil {
t.Fatalf("Failed to restore original user tokenKey: %v", err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByToken(
+13 -12
View File
@@ -2,6 +2,7 @@ package apis
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
@@ -15,7 +16,6 @@ import (
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
@@ -95,6 +95,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
"provider": form.Provider,
"providerId": authUser.Id,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return e.InternalServerError("Failed OAuth2 relation check.", err)
}
switch {
case err == nil && externalAuthRel != nil:
authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
@@ -106,7 +110,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
authRecord = fallbackAuthRecord
case authUser.Email != "":
// look for an existing auth record by the external auth record's email
authRecord, _ = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
authRecord, err = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return e.InternalServerError("Failed OAuth2 auth record check.", err)
}
}
// ---------------------------------------------------------------
@@ -167,7 +174,7 @@ type recordOAuth2LoginForm struct {
func (form *recordOAuth2LoginForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required),
validation.Field(&form.RedirectURL, validation.Required),
)
@@ -178,7 +185,7 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
_, ok := form.collection.OAuth2.GetProviderConfig(name)
if !ok {
return validation.NewError("validation_invalid_provider", fmt.Sprintf("Provider with name %q is missing or is not enabled.", name)).
return validation.NewError("validation_invalid_provider", "Provider with name {{.name}} is missing or is not enabled.").
SetParams(map[string]any{"name": name})
}
@@ -216,12 +223,6 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload[core.FieldNameEmail] = e.OAuth2User.Email
// set a random password if none is set
if v, _ := payload[core.FieldNamePassword].(string); v == "" {
payload[core.FieldNamePassword] = security.RandomString(30)
payload[core.FieldNamePassword+"Confirm"] = payload[core.FieldNamePassword]
}
// map known fields (unless the field was explicitly submitted as part of CreateData)
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
@@ -237,7 +238,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
}
if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
// no existing OAuth2 mapping
// no explicit avatar payload value and existing OAuth2 mapping
e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
// non-empty OAuth2 avatar url
e.OAuth2User.AvatarURL != "" {
@@ -284,7 +285,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
// set random password for users with unverified email
// (this is in case a malicious actor has registered previously with the user email)
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
e.Record.SetPassword(security.RandomString(30))
e.Record.SetRandomPassword()
needUpdate = true
}
+36 -14
View File
@@ -56,18 +56,22 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
}
}
// note: don't exit because it is usually called as part of a separate goroutine
checkClientMessages := func(t testing.TB, clientId string, msg subscriptions.Message, expectedMessages map[string][]string) {
if len(expectedMessages[clientId]) == 0 {
t.Fatalf("Unexpected client %q message, got %s:\n%s", clientId, msg.Name, msg.Data)
t.Errorf("Unexpected client %q message, got %q:\n%q", clientId, msg.Name, msg.Data)
return
}
if msg.Name != "@oauth2" {
t.Fatalf("Expected @oauth2 msg.Name, got %q", msg.Name)
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
return
}
for _, txt := range expectedMessages[clientId] {
if !strings.Contains(string(msg.Data), txt) {
t.Fatalf("Failed to find %q in \n%s", txt, msg.Data)
t.Errorf("Failed to find %q in \n%s", txt, msg.Data)
return
}
}
}
@@ -91,19 +95,37 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
for {
select {
case msg := <-clients["c1"].Channel():
checkClientMessages(t, "c1", msg, expectedMessages)
case msg := <-clients["c2"].Channel():
checkClientMessages(t, "c2", msg, expectedMessages)
case msg := <-clients["c3"].Channel():
checkClientMessages(t, "c3", msg, expectedMessages)
case msg := <-clients["c4"].Channel():
checkClientMessages(t, "c4", msg, expectedMessages)
case msg := <-clients["c5"].Channel():
checkClientMessages(t, "c5", msg, expectedMessages)
case msg, ok := <-clients["c1"].Channel():
if ok {
checkClientMessages(t, "c1", msg, expectedMessages)
} else {
t.Errorf("Unexpected c1 closed channel")
}
case msg, ok := <-clients["c2"].Channel():
if ok {
checkClientMessages(t, "c2", msg, expectedMessages)
} else {
t.Errorf("Unexpected c2 closed channel")
}
case msg, ok := <-clients["c3"].Channel():
if ok {
checkClientMessages(t, "c3", msg, expectedMessages)
} else {
t.Errorf("Unexpected c3 closed channel")
}
case msg, ok := <-clients["c4"].Channel():
if ok {
checkClientMessages(t, "c4", msg, expectedMessages)
} else {
t.Errorf("Unexpected c4 closed channel")
}
case _, ok := <-clients["c5"].Channel():
if ok {
t.Errorf("Expected c5 channel to be closed")
}
case <-ctx.Done():
for _, c := range clients {
close(c.Channel())
c.Discard()
}
return
}
+22 -1
View File
@@ -558,12 +558,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
oldTokenKey := user.TokenKey()
// manually unset the user email
user.SetEmail("")
if err := app.Save(user); err != nil {
if err = app.Save(user); err != nil {
t.Fatal(err)
}
// resave with the old token key since the email change above
// would change it and will make the password token invalid
user.SetTokenKey(oldTokenKey)
if err = app.Save(user); err != nil {
t.Fatalf("Failed to restore original user tokenKey: %v", err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
@@ -949,6 +958,8 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"createData": {
"email": "invalid",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
"name": "test_name",
"username": "test_username",
"rel": "0yxhwia2amd8gec"
@@ -1018,6 +1029,16 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnModelValidate": 4,
"OnRecordValidate": 4,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindFirstRecordByData("users", "username", "test_username")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
},
},
{
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
+20 -15
View File
@@ -51,8 +51,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
}
// since otps are usually simple digit numbers we enforce an extra rate limit rule to prevent enumerations
err = checkRateLimit(e, "@pb_otp_"+event.OTP.Id+event.Record.Id, core.RateLimitRule{MaxRequests: 4, Duration: 180})
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
if err != nil {
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
}
@@ -63,24 +63,29 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
// ---
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
if err != nil {
return err
}
// try to delete the used otp
if e.OTP != nil {
err = e.App.Delete(e.OTP)
// update the user email verified state in case the OTP originate from an email address matching the current record one
//
// note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
otpSentTo := e.OTP.SentTo()
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
e.Record.SetVerified(true)
err = e.App.Save(e.Record)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
"error", err,
"otpId", e.OTP.Id,
"recordId", e.Record.Id,
)
}
}
// note: we don't update the user verified state the same way as in the password reset confirmation
// at the moment because it is not clear whether the otp confirmation came from the user email
// (e.g. it could be from an sms or some other channel)
// try to delete the used otp
err = e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
return nil
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
})
}
+116 -7
View File
@@ -190,7 +190,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid otp with valid password",
Name: "valid otp with valid password (enabled MFA)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
@@ -236,7 +236,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
},
},
{
Name: "valid otp with valid password (disabled MFA)",
Name: "valid otp with valid password and empty sentTo (disabled MFA)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
@@ -249,8 +249,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
t.Fatal(err)
}
// ensure that the user is unverified
user.SetVerified(false)
if err = app.Save(user); err != nil {
t.Fatal(err)
}
// disable MFA
user.Collection().MFA.Enabled = false
if err := app.Save(user.Collection()); err != nil {
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
@@ -297,6 +304,106 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected the user to remain unverified because sentTo != email")
}
},
},
{
Name: "valid otp with valid password and nonempty sentTo=email (disabled MFA)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
"otpId":"` + strings.Repeat("a", 15) + `",
"password":"123456"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// ensure that the user is unverified
user.SetVerified(false)
if err = app.Save(user); err != nil {
t.Fatal(err)
}
// disable MFA
user.Collection().MFA.Enabled = false
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
otp := core.NewOTP(app)
otp.Id = strings.Repeat("a", 15)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
otp.SetSentTo(user.Email())
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
`"record":{`,
`"email":"test@example.com"`,
},
NotExpectedContent: []string{
`"meta":`,
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOTPRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelValidate": 2, // +1 because of the verified user update
// authOrigin create
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
// OTP delete
"OnModelDelete": 1,
"OnModelDeleteExecute": 1,
"OnModelAfterDeleteSuccess": 1,
// user verified update
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
// ---
"OnRecordValidate": 2,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordDelete": 1,
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatal("Expected the user to be marked as verified")
}
},
},
// rate limit checks
@@ -387,11 +494,13 @@ func TestRecordAuthWithOTPManualRateLimiterCheck(t *testing.T) {
}{
{otpAId, "12345", 400},
{otpAId, "12345", 400},
{otpAId, "12345", 400},
{otpAId, "12345", 400},
{otpAId, "123456", 429},
{otpBId, "12345", 400},
{otpBId, "123456", 200},
{otpBId, "12345", 400},
{otpBId, "12345", 400},
{otpAId, "12345", 429},
{otpAId, "123456", 429}, // reject even if it is correct
{otpAId, "123456", 429},
{otpBId, "123456", 429},
}
for _, s := range scenarios {
+192 -64
View File
@@ -1,17 +1,23 @@
package apis
import (
cryptoRand "crypto/rand"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
)
// bindRecordCrudApi registers the record crud api endpoints and
@@ -53,23 +59,27 @@ func recordsList(e *core.RequestEvent) error {
return err
}
fieldsResolver := core.NewRecordFieldResolver(
e.App,
collection,
requestInfo,
// hidden fields are searchable only by superusers
requestInfo.HasSuperuserAuth(),
)
query := e.App.RecordQuery(collection)
searchProvider := search.NewProvider(fieldsResolver).
Query(e.App.RecordQuery(collection))
fieldsResolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil && *collection.ListRule != "" {
expr, err := search.FilterData(*collection.ListRule).BuildExpr(fieldsResolver)
if err != nil {
return err
}
query.AndWhere(expr)
// will be applied by the search provider right before executing the query
// fieldsResolver.UpdateQuery(query)
}
records := []*core.Record{}
// hidden fields are searchable only by superusers
fieldsResolver.SetAllowHiddenFields(requestInfo.HasSuperuserAuth())
searchProvider := search.NewProvider(fieldsResolver).Query(query)
records := []*core.Record{}
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
if err != nil {
return firstApiError(err, e.BadRequestError("", err))
@@ -86,10 +96,44 @@ func recordsList(e *core.RequestEvent) error {
return firstApiError(err, e.InternalServerError("Failed to enrich records", err))
}
// Add a randomized throttle in case of too many empty search filter attempts.
//
// This is just for extra precaution since security researches raised concern regarding the possibility of eventual
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
// This is by design and it is an accepted trade off between performance, usability and correctness.
//
// While technically the below doesn't fully guarantee protection against filter timing attacks, in practice combined with the network latency it makes them even less feasible.
// A properly configured rate limiter or individual fields Hidden checks are better suited if you are really concerned about eventual information disclosure by side-channel attacks.
//
// In all cases it doesn't really matter that much because it doesn't affect the builtin PocketBase security sensitive fields (e.g. password and tokenKey) since they
// are not client-side filterable and in the few places where they need to be compared against an external value, a constant time check is used.
if !e.HasSuperuserAuth() &&
(collection.ListRule != nil && *collection.ListRule != "") &&
(requestInfo.Query["filter"] != "") &&
len(e.Records) == 0 &&
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
randomizedThrottle(150)
}
return e.JSON(http.StatusOK, e.Result)
})
}
var listTimingRateLimitRule = core.RateLimitRule{MaxRequests: 3, Duration: 3}
func randomizedThrottle(softMax int64) {
var timeout int64
randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(softMax))
if err == nil {
timeout = randRange.Int64()
} else {
timeout = softMax
}
time.Sleep(time.Duration(timeout) * time.Millisecond)
}
func recordView(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -169,17 +213,7 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
hasSuperuserAuth := requestInfo.HasSuperuserAuth()
canSkipRuleCheck := hasSuperuserAuth
// special case for the first superuser creation
// ---
if !canSkipRuleCheck && collection.Name == core.CollectionNameSuperusers {
total, totalErr := e.App.CountRecords(core.CollectionNameSuperusers)
canSkipRuleCheck = totalErr == nil && total == 0
}
// ---
if !canSkipRuleCheck && collection.CreateRule == nil {
if !hasSuperuserAuth && collection.CreateRule == nil {
return e.ForbiddenError("Only superusers can perform this action.", nil)
}
@@ -190,6 +224,16 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
}
// set a random password for the OAuth2 ignoring its plain password validators
var skipPlainPasswordRecordValidators bool
if requestInfo.Context == core.RequestInfoContextOAuth2 {
if _, ok := data[core.FieldNamePassword]; !ok {
data[core.FieldNamePassword] = security.RandomString(30)
data[core.FieldNamePassword+"Confirm"] = data[core.FieldNamePassword]
skipPlainPasswordRecordValidators = true
}
}
// replace modifiers fields so that the resolved value is always
// available when accessing requestInfo.Body
requestInfo.Body = data
@@ -200,6 +244,13 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
form.Load(data)
if skipPlainPasswordRecordValidators {
// unset the plain value to skip the plain password field validators
if raw, ok := record.GetRaw(core.FieldNamePassword).(*core.PasswordFieldValue); ok {
raw.Plain = ""
}
}
var isOptFinalizerCalled bool
event := new(core.RecordRequestEvent)
@@ -212,58 +263,75 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
form.SetRecord(e.Record)
// temporary save the record and check it against the create and manage rules
if !canSkipRuleCheck && e.Collection.CreateRule != nil {
// temporary grant manager access level
form.GrantManagerAccess()
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
dummyRecord := e.Record.Clone()
// manually unset the verified field to prevent manage API rule misuse in case the rule relies on it
initialVerified := e.Record.Verified()
if initialVerified {
e.Record.SetVerified(false)
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
// set an id if it doesn't have already
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
if dummyRecord.Id == "" {
dummyRecord.Id = "__temp_id__" + dummyRandomPart
}
createRuleFunc := func(q *dbx.SelectQuery) error {
if *e.Collection.CreateRule == "" {
return nil // no create rule to resolve
}
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
dummyRecord.SetVerified(false)
resolver := core.NewRecordFieldResolver(e.App, e.Collection, requestInfo, true)
expr, err := search.FilterData(*e.Collection.CreateRule).BuildExpr(resolver)
// export the dummy record data into db params
dummyExport, err := dummyRecord.DBExport(e.App)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
}
dummyParams := make(dbx.Params, len(dummyExport))
selects := make([]string, 0, len(dummyExport))
var param string
for k, v := range dummyExport {
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
param = "__pb_create__" + k
dummyParams[param] = v
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
}
// shallow clone the current collection
dummyCollection := *e.Collection
dummyCollection.Id += dummyRandomPart
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
// check non-empty create rule
if *dummyCollection.CreateRule != "" {
ruleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
ruleQuery.AndWhere(expr)
return nil
resolver.UpdateQuery(ruleQuery)
var exists bool
err = ruleQuery.Limit(1).Row(&exists)
if err != nil || !exists {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
}
}
testErr := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
foundRecord, err := txApp.FindRecordById(drySavedRecord.Collection(), drySavedRecord.Id, createRuleFunc)
if err != nil {
return fmt.Errorf("DrySubmit create rule failure: %w", err)
}
// reset the form access level in case it satisfies the Manage API rule
if !hasAuthManageAccess(txApp, requestInfo, foundRecord) {
form.ResetAccess()
}
return nil
})
if testErr != nil {
return e.BadRequestError("Failed to create record.", testErr)
}
// restore initial verified state (it will be further validated on submit)
if initialVerified != e.Record.Verified() {
e.Record.SetVerified(initialVerified)
// check for manage rule access
manageRuleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
form.GrantManagerAccess()
}
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to create record.", err))
return firstApiError(err, e.BadRequestError("Failed to create record", err))
}
err = EnrichRecord(e.RequestEvent, e.Record)
@@ -383,7 +451,12 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
form.SetApp(e.App)
form.SetRecord(e.Record)
if !form.HasManageAccess() && hasAuthManageAccess(e.App, requestInfo, e.Record) {
manageRuleQuery := e.App.DB().Select("(1)").From(e.Collection.Name).AndWhere(dbx.HashExp{
e.Collection.Name + ".id": e.Record.Id,
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
form.GrantManagerAccess()
}
@@ -534,9 +607,28 @@ func recordDataFromRequest(e *core.RequestEvent, record *core.Record) (map[strin
return nil, err
}
if len(uploadedFiles) > 0 {
for k, v := range uploadedFiles {
result[k] = v
for k, files := range uploadedFiles {
uploaded := make([]any, 0, len(files))
// if not remove/prepend/append -> merge with the submitted
// info.Body values to prevent accidental old files deletion
if info.Body[k] != nil &&
!strings.HasPrefix(k, "+") &&
!strings.HasSuffix(k, "+") &&
!strings.HasSuffix(k, "-") {
existing := list.ToUniqueStringSlice(info.Body[k])
for _, name := range existing {
uploaded = append(uploaded, name)
}
}
for _, file := range files {
uploaded = append(uploaded, file)
}
result[k] = uploaded
}
result = record.ReplaceModifiers(result)
}
@@ -597,3 +689,39 @@ func extractUploadedFiles(re *core.RequestEvent, collection *core.Collection, pr
return result, nil
}
// hasAuthManageAccess checks whether the client is allowed to have
// [forms.RecordUpsert] auth management permissions
// (e.g. allowing to change system auth fields without oldPassword).
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection *core.Collection, query *dbx.SelectQuery) bool {
if !collection.IsAuth() {
return false
}
manageRule := collection.ManageRule
if manageRule == nil || *manageRule == "" {
return false // only for superusers (manageRule can't be empty)
}
if requestInfo == nil || requestInfo.Auth == nil {
return false // no auth record
}
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
app.Logger().Error("Manage rule build expression error", "error", err, "collectionId", collection.Id)
return false
}
query.AndWhere(expr)
resolver.UpdateQuery(query)
var exists bool
err = query.Limit(1).Row(&exists)
return err == nil && exists
}
-35
View File
@@ -229,41 +229,6 @@ func TestRecordCrudSuperuserCreate(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "guest creating first superuser",
Method: http.MethodPost,
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
Body: body(),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
},
ExpectedContent: []string{
`"collectionName":"_superusers"`,
`"verified":true`,
},
NotExpectedContent: []string{
// because the action has no auth the email field shouldn't be returned if emailVisibility is not set
`"email"`,
},
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
"OnRecordEnrich": 1,
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
},
},
{
Name: "superusers auth",
Method: http.MethodPost,
+117 -6
View File
@@ -259,6 +259,110 @@ func TestRecordCrudList(t *testing.T) {
"OnRecordEnrich": 4,
},
},
{
Name: "authenticated regular record that matches the collection list rule with hidden field",
Method: http.MethodGet,
URL: "/api/collections/demo3/records",
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// mock hidden field
col.Fields.GetByName("title").SetHidden(true)
col.ListRule = types.Pointer("title ~ 'test'")
if err = app.Save(col); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":4`,
`"items":[{`,
`"id":"1tmknxy2868d869"`,
`"id":"lcl9d87w22ml6jy"`,
`"id":"7nwo8tuiatetxdm"`,
`"id":"mk5fmymtx4wsprk"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 4,
},
},
{
Name: "authenticated regular record filtering with a hidden field",
Method: http.MethodGet,
URL: "/api/collections/demo3/records?filter=title~'test'",
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// mock hidden field
col.Fields.GetByName("title").SetHidden(true)
if err = app.Save(col); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser filtering with a hidden field",
Method: http.MethodGet,
URL: "/api/collections/demo3/records?filter=title~'test'",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// mock hidden field
col.Fields.GetByName("title").SetHidden(true)
if err = app.Save(col); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":4`,
`"items":[{`,
`"id":"1tmknxy2868d869"`,
`"id":"lcl9d87w22ml6jy"`,
`"id":"7nwo8tuiatetxdm"`,
`"id":"mk5fmymtx4wsprk"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 4,
},
},
{
Name: ":rule modifer",
Method: http.MethodGet,
@@ -1684,11 +1788,16 @@ func TestRecordCrudCreate(t *testing.T) {
`"code":"validation_not_unique"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
// validate events are not fired because the unique check will fail during dry submit
// "OnModelValidate": 1,
// "OnRecordValidate": 1,
"*": 0,
"OnRecordCreateRequest": 1,
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateError": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateError": 1,
"OnRecordValidate": 1,
},
},
{
@@ -2345,7 +2454,7 @@ func TestRecordCrudUpdate(t *testing.T) {
}
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123}`,
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123, "files":"300_JdfBOieXAW.png"}`,
}, "files")
if err3 != nil {
t.Fatal(err3)
@@ -2686,6 +2795,8 @@ func TestRecordCrudUpdate(t *testing.T) {
`"id":"mk5fmymtx4wsprk"`,
`"title":"title_test3"`,
`"files":["`,
`"300_JdfBOieXAW.png"`,
`"tmpfile_`,
},
ExpectedEvents: map[string]int{
"*": 0,
+46 -49
View File
@@ -6,11 +6,13 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -20,6 +22,8 @@ const (
fieldsQueryParam = "fields"
)
var ErrMFA = errors.New("mfa required")
// RecordAuthResponse writes standardized json record auth response
// into the specified request context.
//
@@ -70,9 +74,12 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
// require additional authentication
if mfaId != "" {
return e.JSON(http.StatusUnauthorized, map[string]string{
// eagerly write the mfa response and return an err so that
// external middlewars are aware that the auth response requires an extra step
e.JSON(http.StatusUnauthorized, map[string]string{
"mfaId": mfaId,
})
return ErrMFA
}
// ---
@@ -109,20 +116,25 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
}
}
result := map[string]any{
"token": e.Token,
"record": e.Record,
result := struct {
Meta any `json:"meta,omitempty"`
Record *core.Record `json:"record"`
Token string `json:"token"`
}{
Token: e.Token,
Record: e.Record,
}
if e.Meta != nil {
result["meta"] = e.Meta
result.Meta = e.Meta
}
return e.JSON(http.StatusOK, result)
})
}
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule.
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule
// (note: returns true even in case of an error as a safer default).
func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
rule := record.Collection().MFA.Rule
if rule == "" {
@@ -131,7 +143,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
requestInfo, err := e.RequestInfo()
if err != nil {
return false, err
return true, err
}
var exists bool
@@ -144,13 +156,13 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
expr, err := search.FilterData(rule).BuildExpr(resolver)
if err != nil {
return false, err
return true, err
}
resolver.UpdateQuery(query)
err = query.AndWhere(expr).Limit(1).Row(&exists)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
return true, err
}
return exists, nil
@@ -166,11 +178,10 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
}
ok, err := wantsMFA(e, authRecord)
if err != nil {
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
}
if !ok {
if err != nil {
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
}
return "", nil // no mfa needed for this auth record
}
@@ -214,7 +225,7 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
}
if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
deleteMFA()
return "", firstApiError(err, e.BadRequestError("Invalid or expired MFA session.", err))
return "", e.BadRequestError("Invalid or expired MFA session.", err)
}
if mfa.RecordRef() != authRecord.Id || mfa.CollectionRef() != authRecord.Collection().Id {
@@ -470,40 +481,6 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
return nil
}
// hasAuthManageAccess checks whether the client is allowed to have
// [forms.RecordUpsert] auth management permissions
// (e.g. allowing to change system auth fields without oldPassword).
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, record *core.Record) bool {
if !record.Collection().IsAuth() {
return false
}
manageRule := record.Collection().ManageRule
if manageRule == nil || *manageRule == "" {
return false // only for superusers (manageRule can't be empty)
}
if requestInfo == nil || requestInfo.Auth == nil {
return false // no auth record
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := core.NewRecordFieldResolver(app, record.Collection(), requestInfo, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
_, findErr := app.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
return findErr == nil
}
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
var superuserOnlyRuleFields = []string{"@collection.", "@request."}
@@ -594,8 +571,28 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
}
// send email alert for the new origin auth (skip first login)
//
// Note: The "fake" timeout is a temp solution to avoid blocking
// for too long when the SMTP server is not accessible, due
// to the lack of context cancellation support in the underlying
// mailer and net/smtp package.
// The goroutine technically "leaks" but we assume that the OS will
// terminate the connection after some time (usually after 3-4 mins).
if !isFirstLogin && currentOrigin.IsNew() && authRecord.Email() != "" {
if err := mails.SendRecordAuthAlert(e.App, authRecord); err != nil {
mailSent := make(chan error, 1)
timer := time.AfterFunc(15*time.Second, func() {
mailSent <- errors.New("auth alert mail send wait timeout reached")
})
routine.FireAndForget(func() {
err := mails.SendRecordAuthAlert(e.App, authRecord)
timer.Stop()
mailSent <- err
})
err = <-mailSent
if err != nil {
return err
}
}
+5 -4
View File
@@ -2,6 +2,7 @@ package apis_test
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -584,8 +585,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
user.Collection().MFA.Rule = "1=1"
err = apis.RecordAuthResponse(event, user, "example", nil)
if err != nil {
t.Fatalf("Expected nil, got error: %v", err)
if !errors.Is(err, apis.ErrMFA) {
t.Fatalf("Expected ErrMFA, got: %v", err)
}
body := rec.Body.String()
@@ -602,8 +603,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
resetMFAs(user)
err = apis.RecordAuthResponse(event, user, "example", nil)
if err != nil {
t.Fatalf("Expected nil, got error: %v", err)
if !errors.Is(err, apis.ErrMFA) {
t.Fatalf("Expected ErrMFA, got: %v", err)
}
body := rec.Body.String()
+57 -38
View File
@@ -16,6 +16,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/ui"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
@@ -26,12 +27,6 @@ type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
ShowStartBanner bool
// DashboardPath specifies the route path to the superusers dashboard interface
// (default to "/_/{path...}").
//
// Note: Must include the "{path...}" wildcard parameter.
DashboardPath string
// HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
HttpAddr string
@@ -66,12 +61,6 @@ func Serve(app core.App, config ServeConfig) error {
config.AllowedOrigins = []string{"*"}
}
if config.DashboardPath == "" {
config.DashboardPath = "/_/{path...}"
} else if !strings.HasSuffix(config.DashboardPath, "{path...}") {
return errors.New("invalid dashboard path - missing {path...} wildcard")
}
// ensure that the latest migrations are applied before starting the server
err := app.RunAllMigrations()
if err != nil {
@@ -83,16 +72,25 @@ func Serve(app core.App, config ServeConfig) error {
return err
}
pbRouter.Bind(CORSWithConfig(CORSConfig{
pbRouter.Bind(CORS(CORSConfig{
AllowOrigins: config.AllowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
pbRouter.BindFunc(installerRedirect(app, config.DashboardPath))
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
BindFunc(func(e *core.RequestEvent) error {
// ignore root path
if e.Request.PathValue(StaticWildcardParam) != "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
pbRouter.GET(config.DashboardPath, Static(ui.DistDirFS, false)).
BindFunc(dashboardRemoveInstallerParam()).
BindFunc(dashboardCacheControl()).
// add a default CSP
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
}
return e.Next()
}).
Bind(Gzip())
// start http server
@@ -160,6 +158,7 @@ func Serve(app core.App, config ServeConfig) error {
serveEvent.Router = pbRouter
serveEvent.Server = server
serveEvent.CertManager = certManager
serveEvent.InstallerFunc = DefaultInstallerFunc
var listener net.Listener
@@ -206,6 +205,8 @@ func Serve(app core.App, config ServeConfig) error {
}()
// ---------------------------------------------------------------
var baseURL string
// trigger the OnServe hook and start the tcp listener
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
handler, err := e.Router.BuildMux()
@@ -215,10 +216,20 @@ func Serve(app core.App, config ServeConfig) error {
e.Server.Handler = handler
addr := e.Server.Addr
if config.HttpsAddr == "" {
baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr)
} else {
baseURL = "https://"
if len(config.CertificateDomains) > 0 {
baseURL += config.CertificateDomains[0]
} else {
baseURL += serverAddrToHost(serveEvent.Server.Addr)
}
}
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
addr := e.Server.Addr
if addr == "" {
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
if config.HttpsAddr != "" {
addr = ":https"
} else {
@@ -226,11 +237,22 @@ func Serve(app core.App, config ServeConfig) error {
}
}
var lnErr error
listener, err = net.Listen("tcp", addr)
if err != nil {
return err
}
listener, lnErr = net.Listen("tcp", addr)
if e.InstallerFunc != nil {
app := e.App
installerFunc := e.InstallerFunc
routine.FireAndForget(func() {
if err := loadInstaller(app, baseURL, installerFunc); err != nil {
app.Logger().Warn("Failed to initialize installer", "error", err)
}
})
}
return lnErr
return nil
})
if serveHookErr != nil {
return serveHookErr
@@ -241,17 +263,6 @@ func Serve(app core.App, config ServeConfig) error {
}
if config.ShowStartBanner {
schema := "http"
addr := server.Addr
if config.HttpsAddr != "" {
schema = "https"
if len(config.CertificateDomains) > 0 {
addr = config.CertificateDomains[0]
}
}
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
@@ -259,12 +270,12 @@ func Serve(app core.App, config ServeConfig) error {
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, addr),
color.CyanString("%s", baseURL),
)
regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
}
var serveErr error
@@ -275,10 +286,10 @@ func Serve(app core.App, config ServeConfig) error {
}
// start HTTPS server
serveErr = server.ServeTLS(listener, "", "")
serveErr = serveEvent.Server.ServeTLS(listener, "", "")
} else {
// OR start HTTP server
serveErr = server.Serve(listener)
serveErr = serveEvent.Server.Serve(listener)
}
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
@@ -287,6 +298,14 @@ func Serve(app core.App, config ServeConfig) error {
return nil
}
// serverAddrToHost loosely converts http.Server.Addr string into a host to print.
func serverAddrToHost(addr string) string {
if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") {
return "127.0.0.1"
}
return addr
}
type serverErrorLogWriter struct {
app core.App
}
-9
View File
@@ -15,7 +15,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
var allowedOrigins []string
var httpAddr string
var httpsAddr string
var dashboardPath string
command := &cobra.Command{
Use: "serve [domain(s)]",
@@ -40,7 +39,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
err := apis.Serve(app, apis.ServeConfig{
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
DashboardPath: dashboardPath,
ShowStartBanner: showStartBanner,
AllowedOrigins: allowedOrigins,
CertificateDomains: args,
@@ -75,12 +73,5 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
)
command.PersistentFlags().StringVar(
&dashboardPath,
"dashboard",
"/_/{path...}",
"The route path to the superusers dashboard; must include the '{path...}' wildcard parameter",
)
return command
}
+51 -6
View File
@@ -7,21 +7,23 @@ import (
"github.com/fatih/color"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cobra"
)
// NewSuperuserCommand creates and returns new command for managing
// superuser accounts (create, update, delete).
// superuser accounts (create, update, upsert, delete).
func NewSuperuserCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "superuser",
Short: "Manages superuser accounts",
Short: "Manage superusers",
}
command.AddCommand(superuserUpsertCommand(app))
command.AddCommand(superuserCreateCommand(app))
command.AddCommand(superuserUpdateCommand(app))
command.AddCommand(superuserDeleteCommand(app))
command.AddCommand(superuserOTPCommand(app))
return command
}
@@ -30,7 +32,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "upsert",
Example: "superuser upsert test@example.com 1234567890",
Short: "Creates, or updates if email exists, a single superuser account",
Short: "Creates, or updates if email exists, a single superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
@@ -70,7 +72,7 @@ func superuserCreateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "create",
Example: "superuser create test@example.com 1234567890",
Short: "Creates a new superuser account",
Short: "Creates a new superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
@@ -106,7 +108,7 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "update",
Example: "superuser update test@example.com 1234567890",
Short: "Changes the password of a single superuser account",
Short: "Changes the password of a single superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
@@ -140,7 +142,7 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "delete",
Example: "superuser delete test@example.com",
Short: "Deletes an existing superuser account",
Short: "Deletes an existing superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
@@ -164,3 +166,46 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
return command
}
func superuserOTPCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "otp",
Example: "superuser otp test@example.com",
Short: "Creates a new OTP for the specified superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
}
if !superuser.Collection().OTP.Enabled {
return errors.New("OTP is not enabled for the _superusers collection.")
}
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
otp := core.NewOTP(app)
otp.SetCollectionRef(superuser.Collection().Id)
otp.SetRecordRef(superuser.Id)
otp.SetPassword(pass)
err = app.Save(otp)
if err != nil {
return fmt.Errorf("Failed to create OTP: %w", err)
}
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
color.Green("\n├─ Id: %s", otp.Id)
color.Green("├─ Pass: %s", pass)
color.Green("└─ Valid: %ds\n\n", superuser.Collection().OTP.Duration)
return nil
},
}
return command
}
+93
View File
@@ -308,3 +308,96 @@ func TestSuperuserDeleteCommand(t *testing.T) {
})
}
}
func TestSuperuserOTPCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
t.Fatal(err)
}
// remove all existing otps
otps, err := app.FindAllOTPsByCollection(superusersCollection)
if err != nil {
t.Fatal(err)
}
for _, otp := range otps {
err = app.Delete(otp)
if err != nil {
t.Fatal(err)
}
}
scenarios := []struct {
name string
email string
enabled bool
expectError bool
}{
{
"empty email",
"",
true,
true,
},
{
"invalid email",
"invalid",
true,
true,
},
{
"nonexisting superuser",
"test_missing@example.com",
true,
true,
},
{
"existing superuser",
"test@example.com",
true,
false,
},
{
"existing superuser with disabled OTP",
"test@example.com",
false,
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
command := cmd.NewSuperuserCommand(app)
command.SetArgs([]string{"otp", s.email})
superusersCollection.OTP.Enabled = s.enabled
if err = app.SaveNoValidate(superusersCollection); err != nil {
t.Fatal(err)
}
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if hasErr {
return
}
superuser, err := app.FindAuthRecordByEmail(superusersCollection, s.email)
if err != nil {
t.Fatal(err)
}
otps, _ := app.FindAllOTPsByRecord(superuser)
if total := len(otps); total != 1 {
t.Fatalf("Expected 1 OTP, got %d", total)
}
})
}
}
+31 -9
View File
@@ -71,7 +71,7 @@ type App interface {
Settings() *Settings
// Store returns the app runtime store.
Store() *store.Store[any]
Store() *store.Store[string, any]
// Cron returns the app cron instance.
Cron() *cron.Cron
@@ -369,16 +369,9 @@ type App interface {
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [App.ReloadCachedCollections()]
// To manually reload the cache you can call [App.ReloadCachedCollections]
FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error)
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
IsCollectionNameUnique(name string, excludeIds ...string) bool
// FindCollectionReferences returns information for all relation
// fields referencing the provided collection.
//
@@ -387,6 +380,32 @@ type App interface {
// as the excludeIds argument.
FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
// FindCachedCollectionReferences is similar to [App.FindCollectionReferences]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// If you plan making changes to the returned Collection model,
// use [App.FindCollectionReferences] instead.
//
// Caveats:
//
// - The returned Collection should be used only for read-only operations.
// Avoid directly modifying the returned cached Collection as it will affect
// the global cached value even if you don't persist the changes in the database!
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [App.ReloadCachedCollections].
FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
IsCollectionNameUnique(name string, excludeIds ...string) bool
// TruncateCollection deletes all records associated with the provided collection.
//
// The truncate operation is executed in a single transaction,
@@ -1361,6 +1380,9 @@ type App interface {
// OnRecordRequestOTPRequest hook is triggered on each Record
// request OTP API request.
//
// [RecordCreateOTPRequestEvent.Record] could be nil if no matching identity is found, allowing
// you to manually create or locate a different Record model (by reassigning [RecordCreateOTPRequestEvent.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.
+25 -9
View File
@@ -70,7 +70,7 @@ var _ App = (*BaseApp)(nil)
type BaseApp struct {
config *BaseAppConfig
txInfo *txAppInfo
store *store.Store[any]
store *store.Store[string, any]
cron *cron.Cron
settings *Settings
subscriptionsBroker *subscriptions.Broker
@@ -194,7 +194,7 @@ type BaseApp struct {
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
settings: newDefaultSettings(),
store: store.New[any](nil),
store: store.New[string, any](nil),
cron: cron.New(),
subscriptionsBroker: subscriptions.NewBroker(),
config: &config,
@@ -340,8 +340,6 @@ func (app *BaseApp) initHooks() {
// NB! Note that using the returned app instance may cause data integrity errors
// since the Record validations and data normalizations (including files uploads)
// rely on the app hooks to work.
//
// @todo consider caching the created instance?
func (app *BaseApp) UnsafeWithoutHooks() App {
clone := *app
@@ -534,7 +532,7 @@ func (app *BaseApp) Settings() *Settings {
}
// Store returns the app runtime store.
func (app *BaseApp) Store() *store.Store[any] {
func (app *BaseApp) Store() *store.Store[string, any] {
return app.store
}
@@ -603,6 +601,14 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
}
if len(ae.Message.InlineAttachments) > 0 {
attachmentKeys := make([]string, 0, len(ae.Message.InlineAttachments))
for k := range ae.Message.InlineAttachments {
attachmentKeys = append(attachmentKeys, k)
}
fmt.Fprintf(mailLog, "├─ InlineAttachments: %v\n", attachmentKeys)
}
const indentation = " "
if ae.Message.Text != "" {
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
@@ -1140,8 +1146,8 @@ var sqlLogReplacements = map[string]string{
"]]": "`",
"<nil>": "NULL",
}
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[(.+)\.(.+)\]\]`)
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{(.+)\.(.+)\}\}`)
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`)
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`)
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
// The query is still not suitable for execution and should be used only for log and debug purposes
@@ -1238,8 +1244,18 @@ func (app *BaseApp) registerBaseHooks() {
Priority: 999,
})
app.Cron().Add("__pbPragmaOptimize__", "0 0 * * *", func() {
_, execErr := app.DB().NewQuery("PRAGMA optimize").Execute()
app.Cron().Add("__pbDBOptimize__", "0 0 * * *", func() {
_, execErr := app.NonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the main DB", slog.String("error", execErr.Error()))
}
_, execErr = app.AuxNonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
}
_, execErr = app.DB().NewQuery("PRAGMA optimize").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
}
+69 -39
View File
@@ -76,6 +76,11 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
createErr := e.App.RunInTransaction(func(txApp App) error {
return txApp.AuxRunInTransaction(func(txApp App) error {
// run manual checkpoint and truncate the WAL files
// (errors are ignored because it is not that important and the PRAGMA may not be supported by the used driver)
txApp.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
txApp.AuxDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
return archive.Create(txApp.DataDir(), tempPath, e.Exclude...)
})
})
@@ -137,6 +142,10 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
//
// If a failure occure during the restore process the dir changes are reverted.
// If for whatever reason the revert is not possible, it panics.
//
// Note that if your pb_data has custom network mounts as subdirectories, then
// it is possible the restore to fail during the `os.Rename` operations
// (see https://github.com/pocketbase/pocketbase/issues/4647).
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
@@ -157,6 +166,13 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return errors.New("restore is not supported on Windows")
}
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
return err
@@ -165,57 +181,71 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
fsys.SetContext(e.Context)
// fetch the backup file in a temp location
br, err := fsys.GetFile(name)
if err != nil {
return err
}
defer br.Close()
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
if ok, _ := fsys.Exists(name); !ok {
return fmt.Errorf("missing or invalid backup file %q to restore", name)
}
// create a temp zip file from the blob.Reader and try to extract it
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
if err != nil {
return err
}
defer os.Remove(tempZip.Name())
if _, err := io.Copy(tempZip, br); err != nil {
return err
}
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
defer os.RemoveAll(extractedDataDir)
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
return err
// extract the zip
if e.App.Settings().Backups.S3.Enabled {
br, err := fsys.GetFile(name)
if err != nil {
return err
}
defer br.Close()
// create a temp zip file from the blob.Reader and try to extract it
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
if err != nil {
return err
}
defer os.Remove(tempZip.Name())
defer tempZip.Close() // note: this technically shouldn't be necessary but it is here to workaround platforms discrepancies
_, err = io.Copy(tempZip, br)
if err != nil {
return err
}
err = archive.Extract(tempZip.Name(), extractedDataDir)
if err != nil {
return err
}
// remove the temp zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
_ = tempZip.Close()
err = os.Remove(tempZip.Name())
if err != nil {
e.App.Logger().Warn(
"[RestoreBackup] Failed to remove the temp zip backup file",
slog.String("file", tempZip.Name()),
slog.String("error", err.Error()),
)
}
} else {
// manually construct the local path to avoid creating a copy of the zip file
// since the blob reader currently doesn't implement ReaderAt
zipPath := filepath.Join(app.DataDir(), LocalBackupsDirName, filepath.Base(name))
err = archive.Extract(zipPath, extractedDataDir)
if err != nil {
return err
}
}
// ensure that a database file exists
// ensure that at least a database file exists
extractedDB := filepath.Join(extractedDataDir, "data.db")
if _, err := os.Stat(extractedDB); err != nil {
return fmt.Errorf("data.db file is missing or invalid: %w", err)
}
// remove the extracted zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
if err := os.Remove(tempZip.Name()); err != nil {
e.App.Logger().Debug(
"[RestoreBackup] Failed to remove the temp zip backup file",
slog.String("file", tempZip.Name()),
slog.String("error", err.Error()),
)
}
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
if err := osutils.MoveDirContent(e.App.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
@@ -252,7 +282,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// registerAutobackupHooks registers the autobackup app serve hooks.
func (app *BaseApp) registerAutobackupHooks() {
const jobId = "__auto_pb_backup__"
const jobId = "__pbAutoBackup__"
loadJob := func() {
rawSchedule := app.Settings().Backups.Cron
+6
View File
@@ -86,6 +86,12 @@ func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing b
continue
}
if imported.Fields.GetById(f.GetId()) == nil {
// replace with the existing id to prevent accidental column deletion
// since otherwise the imported field will be treated as a new one
found := imported.Fields.GetByName(f.GetName())
if found != nil && found.Type() == f.Type() {
found.SetId(f.GetId())
}
imported.Fields.Add(f)
}
}
+58 -14
View File
@@ -28,15 +28,20 @@ const (
const systemHookIdCollection = "__pbCollectionSystemHook__"
const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
func (app *BaseApp) registerCollectionHooks() {
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -48,10 +53,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -63,10 +71,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -78,10 +89,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -93,10 +107,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
return me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
@@ -108,10 +125,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -123,10 +143,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -138,10 +161,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -153,10 +179,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
return me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
@@ -168,10 +197,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -183,10 +215,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -198,10 +233,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -213,10 +251,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
return me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
@@ -890,7 +931,7 @@ func (c *Collection) initIdField() {
Required: true,
Min: 15,
Max: 15,
Pattern: `^[a-z0-9]+$`,
Pattern: defaultLowercaseRecordIdPattern,
AutogeneratePattern: `[a-z0-9]{15}`,
}
@@ -902,6 +943,9 @@ func (c *Collection) initIdField() {
field.Required = true
field.PrimaryKey = true
field.Hidden = false
if field.Pattern == "" {
field.Pattern = defaultLowercaseRecordIdPattern
}
}
}
+2 -2
View File
@@ -445,7 +445,7 @@ func checkForDuplicatedProviders(value any) error {
if _, ok := existing[c.Name]; ok {
return validation.Errors{
strconv.Itoa(i): validation.Errors{
"name": validation.NewError("validation_duplicated_provider", "The provider "+c.Name+" is already registered.").
"name": validation.NewError("validation_duplicated_provider", "The provider {{.name}} is already registered.").
SetParams(map[string]any{"name": c.Name}),
},
}
@@ -493,7 +493,7 @@ func checkProviderName(value any) error {
}
if _, err := auth.NewProviderByName(name); err != nil {
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name "+name+".").
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name {{.name}}.").
SetParams(map[string]any{"name": name})
}
+71 -28
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/pocketbase/dbx"
@@ -75,16 +76,16 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
return m, nil
}
// FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// Returns [sql.ErrNoRows] if no Collection is found for consistency
// with the [App.FindCollectionByNameOrId] method.
// with the [BaseApp.FindCollectionByNameOrId] method.
//
// If you plan making changes to the returned Collection model,
// use [App.FindCollectionByNameOrId] instead.
// use [BaseApp.FindCollectionByNameOrId] instead.
//
// Caveats:
//
@@ -94,7 +95,7 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [App.ReloadCachedCollections()]
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
if collections == nil {
@@ -111,30 +112,6 @@ func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection
return nil, sql.ErrNoRows
}
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
}
query := app.CollectionQuery().
Select("count(*)").
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
return query.Row(&exists) == nil && !exists
}
// FindCollectionReferences returns information for all relation fields
// referencing the provided collection.
//
@@ -168,6 +145,72 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
return result, nil
}
// FindCachedCollectionReferences is similar to [BaseApp.FindCollectionReferences]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// If you plan making changes to the returned Collection model,
// use [BaseApp.FindCollectionReferences] instead.
//
// Caveats:
//
// - The returned Collection should be used only for read-only operations.
// Avoid directly modifying the returned cached Collection as it will affect
// the global cached value even if you don't persist the changes in the database!
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
func (app *BaseApp) FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
if collections == nil {
// cache is not initialized yet (eg. run in a system migration)
return app.FindCollectionReferences(collection, excludeIds...)
}
result := map[*Collection][]Field{}
for _, c := range collections {
if slices.Contains(excludeIds, c.Id) {
continue
}
for _, rawField := range c.Fields {
f, ok := rawField.(*RelationField)
if ok && f.CollectionId == collection.Id {
result[c] = append(result[c], f)
}
}
}
return result, nil
}
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
}
query := app.CollectionQuery().
Select("count(*)").
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
return query.Row(&exists) == nil && !exists
}
// TruncateCollection deletes all records associated with the provided collection.
//
// The truncate operation is executed in a single transaction,
+109 -28
View File
@@ -208,34 +208,6 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
run(false)
}
func TestIsCollectionNameUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
excludeId string
expected bool
}{
{"", "", false},
{"demo1", "", false},
{"Demo1", "", false},
{"new", "", true},
{"demo1", "wsmn24bux7wo113", true},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
result := app.IsCollectionNameUnique(s.name, s.excludeId)
if result != s.expected {
t.Errorf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestFindCollectionReferences(t *testing.T) {
t.Parallel()
@@ -288,6 +260,115 @@ func TestFindCollectionReferences(t *testing.T) {
}
}
func TestFindCachedCollectionReferences(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
totalQueries := 0
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
run := func(withCache bool) {
var expectedTotalQueries int
if withCache {
err := app.ReloadCachedCollections()
if err != nil {
t.Fatal(err)
}
} else {
app.Store().Reset(nil)
expectedTotalQueries = 1
}
totalQueries = 0
result, err := app.FindCachedCollectionReferences(
collection,
collection.Id,
// test whether "nonempty" exclude ids condition will be skipped
"",
"",
)
if err != nil {
t.Fatal(err)
}
if len(result) != 1 {
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
}
expectedFields := []string{
"rel_one_no_cascade",
"rel_one_no_cascade_required",
"rel_one_cascade",
"rel_one_unique",
"rel_many_no_cascade",
"rel_many_no_cascade_required",
"rel_many_cascade",
"rel_many_unique",
}
for col, fields := range result {
if col.Name != "demo4" {
t.Fatalf("Expected collection demo4, got %s", col.Name)
}
if len(fields) != len(expectedFields) {
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
}
for i, f := range fields {
if !slices.Contains(expectedFields, f.GetName()) {
t.Fatalf("[%d] Didn't expect field %v", i, f)
}
}
}
if totalQueries != expectedTotalQueries {
t.Fatalf("Expected %d totalQueries, got %d", expectedTotalQueries, totalQueries)
}
}
run(true)
run(false)
}
func TestIsCollectionNameUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
excludeId string
expected bool
}{
{"", "", false},
{"demo1", "", false},
{"Demo1", "", false},
{"new", "", true},
{"demo1", "wsmn24bux7wo113", true},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
result := app.IsCollectionNameUnique(s.name, s.excludeId)
if result != s.expected {
t.Errorf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestFindCollectionTruncate(t *testing.T) {
t.Parallel()
+1 -3
View File
@@ -349,9 +349,7 @@ func createCollectionIndexes(app App, collection *Collection) error {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
).SetParams(map[string]any{
"indexName": parsed.IndexName,
})
)
continue
}
}
+24 -8
View File
@@ -268,7 +268,7 @@ func (validator *collectionValidator) checkFieldDuplicates(value any) error {
strconv.Itoa(i): validation.Errors{
"name": validation.NewError(
"validation_duplicated_field_name",
fmt.Sprintf("Duplicated or invalid field name %q", field.GetName()),
"Duplicated or invalid field name {{.fieldName}}",
).SetParams(map[string]any{
"fieldName": field.GetName(),
}),
@@ -452,12 +452,12 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
for _, name := range names {
field := cv.new.Fields.GetByName(name)
if field == nil {
return validation.NewError("validation_missing_field", fmt.Sprintf("Invalid or missing field %q", name)).
return validation.NewError("validation_missing_field", "Invalid or missing field {{.fieldName}}").
SetParams(map[string]any{"fieldName": name})
}
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
return validation.NewError("validation_missing_unique_constraint", fmt.Sprintf("The field %q doesn't have a UNIQUE constraint.", name)).
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
SetParams(map[string]any{"fieldName": name})
}
}
@@ -566,7 +566,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_existing_index_name",
"The index name is already used in "+usedTblName+" collection.",
"The index name is already used in {{.usedTableName}} collection.",
).SetParams(map[string]any{"usedTableName": usedTblName}),
}
}
@@ -608,6 +608,12 @@ func (cv *collectionValidator) checkIndexes(value any) error {
continue
}
// reset collate and sort since they are not important for the unique constraint
for i := range oldParsed.Columns {
oldParsed.Columns[i].Collate = ""
oldParsed.Columns[i].Sort = ""
}
oldParsedStr := oldParsed.Build()
for _, column := range oldParsed.Columns {
@@ -621,19 +627,29 @@ func (cv *collectionValidator) checkIndexes(value any) error {
newParsed := dbutils.ParseIndex(newIndex)
// exclude the non-important identifiers from the check
newParsed.SchemaName = oldParsed.SchemaName
newParsed.IndexName = oldParsed.IndexName
newParsed.TableName = oldParsed.TableName
// exclude partial constraints
newParsed.Where = oldParsed.Where
// reset collate and sort
for i := range newParsed.Columns {
newParsed.Columns[i].Collate = ""
newParsed.Columns[i].Sort = ""
}
if oldParsedStr == newParsed.Build() {
hasMatch = true
continue
break
}
}
if !hasMatch {
return validation.NewError(
"validation_unique_system_field_index_change",
fmt.Sprintf("Unique index definition on system fields (%q) cannot be changed.", f.GetName()),
"validation_invalid_unique_system_field_index",
"Unique index definition on system fields ({{.fieldName}}) is invalid or missing.",
).SetParams(map[string]any{"fieldName": f.GetName()})
}
@@ -653,7 +669,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
return validation.NewError(
"validation_missing_required_unique_index",
`Missing required unique index for field "`+name+`".`,
`Missing required unique index for field "{{.fieldName}}".`,
).SetParams(map[string]any{"fieldName": name})
}
}
+88 -4
View File
@@ -550,7 +550,7 @@ func TestCollectionValidate(t *testing.T) {
expectedErrors: []string{"indexes"},
},
{
name: "changing index on system field",
name: "changing partial constraint of existing index on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
@@ -571,7 +571,91 @@ func TestCollectionValidate(t *testing.T) {
// replace the index with a partial one
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_unique_demo2_title", true, "title", "1 = 1")
demo2.AddIndex("idx_new_demo2_title", true, "title", "1 = 1")
return demo2, nil
},
expectedErrors: []string{},
},
{
name: "changing column sort and collate of existing index on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// mark the title field as system
demo2.Fields.GetByName("title").SetSystem(true)
if err = app.Save(demo2); err != nil {
return nil, err
}
// refresh
demo2, err = app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// replace the index with a new one for the same column but with collate and sort
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_new_demo2_title", true, "title COLLATE test ASC", "")
return demo2, nil
},
expectedErrors: []string{},
},
{
name: "adding new column to index on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// mark the title field as system
demo2.Fields.GetByName("title").SetSystem(true)
if err = app.Save(demo2); err != nil {
return nil, err
}
// refresh
demo2, err = app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// replace the index with a non-unique one
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_new_title", false, "title, id", "")
return demo2, nil
},
expectedErrors: []string{"indexes"},
},
{
name: "changing index type on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// mark the title field as system
demo2.Fields.GetByName("title").SetSystem(true)
if err = app.Save(demo2); err != nil {
return nil, err
}
// refresh
demo2, err = app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// replace the index with a non-unique one (partial constraints are ignored)
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_new_title", false, "title", "1=1")
return demo2, nil
},
@@ -620,7 +704,7 @@ func TestCollectionValidate(t *testing.T) {
collection: func(app core.App) (*core.Collection, error) {
c := core.NewBaseCollection("new_auth")
c.Fields = core.NewFieldsList(
&core.TextField{Name: "id", PrimaryKey: true, Required: true},
&core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
)
return c, nil
},
@@ -631,7 +715,7 @@ func TestCollectionValidate(t *testing.T) {
collection: func(app core.App) (*core.Collection, error) {
c := core.NewBaseCollection("new_auth")
c.Fields = core.NewFieldsList(
&core.TextField{Name: "id", PrimaryKey: true, Required: true},
&core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
&core.TextField{Id: "f1", Name: "Test"}, // case-insensitive
&core.BoolField{Id: "f2", Name: "test"},
)
+2 -2
View File
@@ -59,7 +59,7 @@ func GenerateDefaultRandomId() string {
// crc32Checksum generates a stringified crc32 checksum from the provided plain string.
func crc32Checksum(str string) string {
return strconv.Itoa(int(crc32.ChecksumIEEE([]byte(str))))
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
}
// ModelQuery creates a new preconfigured select app.DB() query with preset
@@ -464,7 +464,7 @@ func validateCollectionId(app App, optTypes ...string) validation.RuleFunc {
return validation.NewError(
"validation_invalid_collection_type",
fmt.Sprintf("Invalid collection type - must be %s.", strings.Join(optTypes, ", ")),
).SetParams(map[string]any{"types": optTypes})
)
}
return nil
+10 -8
View File
@@ -46,14 +46,16 @@ func baseLockRetry(op func(attempt int) error, maxRetries int) error {
Retry:
err := op(attempt)
if err != nil &&
attempt <= maxRetries &&
// we are checking the plain error text to handle both cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(getDefaultRetryInterval(attempt))
attempt++
goto Retry
if err != nil && attempt <= maxRetries {
errStr := err.Error()
// we are checking the error against the plain error texts since the codes could vary between drivers
if strings.Contains(errStr, "database is locked") ||
strings.Contains(errStr, "table is locked") {
// wait and retry
time.Sleep(getDefaultRetryInterval(attempt))
attempt++
goto Retry
}
}
return err
+1
View File
@@ -33,6 +33,7 @@ func TestBaseLockRetry(t *testing.T) {
{nil, 3, 1},
{errors.New("test"), 3, 1},
{errors.New("database is locked"), 3, 3},
{errors.New("table is locked"), 3, 3},
}
for i, s := range scenarios {
+178
View File
@@ -168,16 +168,28 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
})
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
afterCreateHookCalls++
return e.Next()
})
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
afterUpdateHookCalls++
return e.Next()
})
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
afterDeleteHookCalls++
return e.Next()
})
@@ -233,3 +245,169 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
}
}
func TestTransactionFromInnerCreateHook(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.OnRecordCreateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
originalApp := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() {
e.App = originalApp
}()
nextErr := e.Next()
return nextErr
})
})
app.OnRecordAfterCreateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
// perform a db query with the app instance to ensure that it is still valid
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatalf("Failed to perform a db query after tx success: %v", err)
}
return e.Next()
})
collection, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
record := core.NewRecord(collection)
record.Set("title", "test_inner_tx")
if err = app.Save(record); err != nil {
t.Fatalf("Create failed: %v", err)
}
expectedHookCalls := map[string]int{
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
}
for k, total := range expectedHookCalls {
if found, ok := app.EventCalls[k]; !ok || total != found {
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
}
}
}
func TestTransactionFromInnerUpdateHook(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.OnRecordUpdateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
originalApp := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() {
e.App = originalApp
}()
nextErr := e.Next()
return nextErr
})
})
app.OnRecordAfterUpdateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
// perform a db query with the app instance to ensure that it is still valid
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatalf("Failed to perform a db query after tx success: %v", err)
}
return e.Next()
})
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatal(err)
}
if err = app.Save(existingModel); err != nil {
t.Fatalf("Update failed: %v", err)
}
expectedHookCalls := map[string]int{
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
}
for k, total := range expectedHookCalls {
if found, ok := app.EventCalls[k]; !ok || total != found {
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
}
}
}
func TestTransactionFromInnerDeleteHook(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.OnRecordDeleteExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
originalApp := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() {
e.App = originalApp
}()
nextErr := e.Next()
return nextErr
})
})
app.OnRecordAfterDeleteSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
// perform a db query with the app instance to ensure that it is still valid
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatalf("Failed to perform a db query after tx success: %v", err)
}
return e.Next()
})
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatal(err)
}
if err = app.Delete(existingModel); err != nil {
t.Fatalf("Delete failed: %v", err)
}
expectedHookCalls := map[string]int{
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
}
for k, total := range expectedHookCalls {
if found, ok := app.EventCalls[k]; !ok || total != found {
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
}
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ func (e *RequestEvent) HasSuperuserAuth() bool {
// RequestInfo parses the current request into RequestInfo instance.
//
// Note that the returned result is cached to avoid copying the request data multiple times
// but the auth state and other common store items are always refreshed in case they were changed my another handler.
// but the auth state and other common store items are always refreshed in case they were changed by another handler.
func (e *RequestEvent) RequestInfo() (*RequestInfo, error) {
e.mu.Lock()
defer e.mu.Unlock()
+7 -7
View File
@@ -17,7 +17,7 @@ func TestEventRequestRealIP(t *testing.T) {
"CF-Connecting-IP": {"1.2.3.4", "1.1.1.1"},
"Fly-Client-IP": {"1.2.3.4", "1.1.1.2"},
"X-Real-IP": {"1.2.3.4", "1.1.1.3,1.1.1.4"},
"X-Forward-For": {"1.2.3.4", "invalid,1.1.1.5,1.1.1.6,invalid"},
"X-Forwarded-For": {"1.2.3.4", "invalid,1.1.1.5,1.1.1.6,invalid"},
}
scenarios := []struct {
@@ -44,28 +44,28 @@ func TestEventRequestRealIP(t *testing.T) {
{
"trusted X-Real-IP (rightmost)",
headers,
[]string{"header1", "x-real-ip", "x-forward-for"},
[]string{"header1", "x-real-ip", "x-forwarded-for"},
false,
"1.1.1.4",
},
{
"trusted X-Real-IP (leftmost)",
headers,
[]string{"header1", "x-real-ip", "x-forward-for"},
[]string{"header1", "x-real-ip", "x-forwarded-for"},
true,
"1.1.1.3",
},
{
"trusted X-Forward-For (rightmost)",
"trusted X-Forwarded-For (rightmost)",
headers,
[]string{"header1", "x-forward-for"},
[]string{"header1", "x-forwarded-for"},
false,
"1.1.1.6",
},
{
"trusted X-Forward-For (leftmost)",
"trusted X-Forwarded-For (leftmost)",
headers,
[]string{"header1", "x-forward-for"},
[]string{"header1", "x-forwarded-for"},
true,
"1.1.1.5",
},
+44 -3
View File
@@ -103,6 +103,22 @@ type ServeEvent struct {
Router *router.Router[*RequestEvent]
Server *http.Server
CertManager *autocert.Manager
// InstallerFunc is the "installer" function that is called after
// successful server tcp bind but only if there is no explicit
// superuser record created yet.
//
// It runs in a separate goroutine and its default value is [apis.DefaultInstallerFunc].
//
// It receives a system superuser record as argument that you can use to generate
// a short-lived auth token (e.g. systemSuperuser.NewStaticAuthToken(30 * time.Minute))
// and concatenate it as query param for your installer page
// (if you are using the client-side SDKs, you can then load the
// token with pb.authStore.save(token) and perform any Web API request
// e.g. creating a new superuser).
//
// Set it to nil if you want to skip the installer.
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
}
// -------------------------------------------------------------------
@@ -173,8 +189,8 @@ type ModelEvent struct {
}
type ModelErrorEvent struct {
ModelEvent
Error error
ModelEvent
}
// -------------------------------------------------------------------
@@ -196,8 +212,8 @@ type RecordEvent struct {
}
type RecordErrorEvent struct {
RecordEvent
Error error
RecordEvent
}
func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
@@ -216,6 +232,12 @@ func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
// }
}
func syncRecordEventWithModelEvent(re *RecordEvent, me *ModelEvent) {
re.App = me.App
re.Context = me.Context
re.Type = me.Type
}
func newRecordEventFromModelEvent(me *ModelEvent) (*RecordEvent, bool) {
record, ok := me.Model.(*Record)
if !ok {
@@ -253,6 +275,11 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
me.Error = re.Error
}
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
me.Error = re.Error
}
// -------------------------------------------------------------------
// Collection events data
// -------------------------------------------------------------------
@@ -272,8 +299,8 @@ type CollectionEvent struct {
}
type CollectionErrorEvent struct {
CollectionEvent
Error error
CollectionEvent
}
func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
@@ -283,6 +310,15 @@ func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
me.Model = ce.Collection
}
func syncCollectionEventWithModelEvent(ce *CollectionEvent, me *ModelEvent) {
ce.App = me.App
ce.Context = me.Context
ce.Type = me.Type
if c, ok := me.Model.(*Collection); ok {
ce.Collection = c
}
}
func newCollectionEventFromModelEvent(me *ModelEvent) (*CollectionEvent, bool) {
record, ok := me.Model.(*Collection)
if !ok {
@@ -316,6 +352,11 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
me.Error = ce.Error
}
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
me.Error = ce.Error
}
// -------------------------------------------------------------------
// File API events data
// -------------------------------------------------------------------
+4 -2
View File
@@ -13,6 +13,8 @@ import (
var fieldNameRegex = regexp.MustCompile(`^\w+$`)
const maxSafeJSONInt int64 = 1<<53 - 1
// Commonly used field names.
const (
FieldNameId = "id"
@@ -191,7 +193,7 @@ func DefaultFieldIdValidationRule(value any) error {
rules := []validation.Rule{
validation.Required,
validation.Length(1, 255),
validation.Length(1, 100),
}
for _, r := range rules {
@@ -217,7 +219,7 @@ func DefaultFieldNameValidationRule(value any) error {
rules := []validation.Rule{
validation.Required,
validation.Length(1, 255),
validation.Length(1, 100),
validation.Match(fieldNameRegex),
validation.NotIn(excludeNames...),
validation.By(checkForVia),
+41 -16
View File
@@ -16,6 +16,9 @@ func init() {
const FieldTypeAutodate = "autodate"
// used to keep track of the last set autodate value
const autodateLastKnownPrefix = internalCustomFieldKeyPrefix + "_last_autodate_"
var (
_ Field = (*AutodateField)(nil)
_ SetterFinder = (*AutodateField)(nil)
@@ -167,24 +170,46 @@ func (f *AutodateField) Intercept(
actionFunc func() error,
) error {
switch actionName {
case InterceptorActionCreate:
// ignore for custom date manually set with record.SetRaw()
if f.OnCreate && !f.hasBeenManuallyChanged(record) {
record.SetRaw(f.Name, types.NowDateTime())
case InterceptorActionCreateExecute:
// ignore if a date different from the old one was manually set with SetRaw
if f.OnCreate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
now := types.NowDateTime()
record.SetRaw(f.Name, now)
record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
}
case InterceptorActionUpdate:
// ignore for custom date manually set with record.SetRaw()
if f.OnUpdate && !f.hasBeenManuallyChanged(record) {
record.SetRaw(f.Name, types.NowDateTime())
if err := actionFunc(); err != nil {
return err
}
record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
return nil
case InterceptorActionUpdateExecute:
// ignore if a date different from the old one was manually set with SetRaw
if f.OnUpdate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
now := types.NowDateTime()
record.SetRaw(f.Name, now)
record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
}
if err := actionFunc(); err != nil {
return err
}
record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
return nil
default:
return actionFunc()
}
}
func (f *AutodateField) getLastKnownValue(record *Record) types.DateTime {
v := record.GetDateTime(autodateLastKnownPrefix + f.Name)
if !v.IsZero() {
return v
}
return actionFunc()
}
func (f *AutodateField) hasBeenManuallyChanged(record *Record) bool {
vNew, _ := record.GetRaw(f.Name).(types.DateTime)
vOld, _ := record.Original().GetRaw(f.Name).(types.DateTime)
return vNew.String() != vOld.String()
return record.Original().GetDateTime(f.Name)
}
+102 -10
View File
@@ -2,6 +2,7 @@ package core_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -226,6 +228,13 @@ func TestAutodateFieldFindSetter(t *testing.T) {
})
}
func cutMilliseconds(datetime string) string {
if len(datetime) > 19 {
return datetime[:19]
}
return datetime
}
func TestAutodateFieldIntercept(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -255,7 +264,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
},
{
"create with zero value (disabled onCreate)",
core.InterceptorActionCreate,
core.InterceptorActionCreateExecute,
&core.AutodateField{Name: "test", OnCreate: false, OnUpdate: true},
func() *core.Record {
return core.NewRecord(collection)
@@ -264,7 +273,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
},
{
"create with zero value",
core.InterceptorActionCreate,
core.InterceptorActionCreateExecute,
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
func() *core.Record {
return core.NewRecord(collection)
@@ -273,7 +282,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
},
{
"create with non-zero value",
core.InterceptorActionCreate,
core.InterceptorActionCreateExecute,
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
func() *core.Record {
record := core.NewRecord(collection)
@@ -284,7 +293,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
},
{
"update with zero value (disabled onUpdate)",
core.InterceptorActionUpdate,
core.InterceptorActionUpdateExecute,
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: false},
func() *core.Record {
return core.NewRecord(collection)
@@ -293,7 +302,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
},
{
"update with zero value",
core.InterceptorActionUpdate,
core.InterceptorActionUpdateExecute,
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
func() *core.Record {
return core.NewRecord(collection)
@@ -302,7 +311,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
},
{
"update with non-zero value",
core.InterceptorActionUpdate,
core.InterceptorActionUpdateExecute,
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
func() *core.Record {
record := core.NewRecord(collection)
@@ -341,9 +350,92 @@ func TestAutodateFieldIntercept(t *testing.T) {
}
}
func cutMilliseconds(datetime string) string {
if len(datetime) > 19 {
return datetime[:19]
func TestAutodateRecordResave(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
record, err := app.FindRecordById(collection, "llvuca81nly1qls")
if err != nil {
t.Fatal(err)
}
lastUpdated := record.GetDateTime("updated")
// save with autogenerated date
err = app.Save(record)
if err != nil {
t.Fatal(err)
}
newUpdated := record.GetDateTime("updated")
if newUpdated.Equal(lastUpdated) {
t.Fatalf("[0] Expected updated to change, got %v", newUpdated)
}
lastUpdated = newUpdated
// save with custom date
manualUpdated := lastUpdated.Add(-1 * time.Minute)
record.SetRaw("updated", manualUpdated)
err = app.Save(record)
if err != nil {
t.Fatal(err)
}
newUpdated = record.GetDateTime("updated")
if !newUpdated.Equal(manualUpdated) {
t.Fatalf("[1] Expected updated to be the manual set date %v, got %v", manualUpdated, newUpdated)
}
lastUpdated = newUpdated
// save again with autogenerated date
err = app.Save(record)
if err != nil {
t.Fatal(err)
}
newUpdated = record.GetDateTime("updated")
if newUpdated.Equal(lastUpdated) {
t.Fatalf("[2] Expected updated to change, got %v", newUpdated)
}
lastUpdated = newUpdated
// simulate save failure
app.OnRecordUpdateExecute(collection.Id).Bind(&hook.Handler[*core.RecordEvent]{
Id: "test_failure",
Func: func(*core.RecordEvent) error {
return errors.New("test")
},
Priority: 9999999999, // as latest as possible
})
// save again with autogenerated date (should fail)
err = app.Save(record)
if err == nil {
t.Fatal("Expected save failure")
}
// updated should still be set even after save failure
newUpdated = record.GetDateTime("updated")
if newUpdated.Equal(lastUpdated) {
t.Fatalf("[3] Expected updated to change, got %v", newUpdated)
}
lastUpdated = newUpdated
// cleanup the error and resave again
app.OnRecordUpdateExecute(collection.Id).Unbind("test_failure")
err = app.Save(record)
if err != nil {
t.Fatal(err)
}
newUpdated = record.GetDateTime("updated")
if newUpdated.Equal(lastUpdated) {
t.Fatalf("[4] Expected updated to change, got %v", newUpdated)
}
return datetime
}
+3 -4
View File
@@ -2,7 +2,6 @@ package core
import (
"context"
"fmt"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core/validators"
@@ -48,7 +47,7 @@ type EditorField struct {
// ---
// MaxSize specifies the maximum size of the allowed field value (in bytes).
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
//
// If zero, a default limit of ~5MB is applied.
MaxSize int64 `form:"maxSize" json:"maxSize"`
@@ -137,7 +136,7 @@ func (f *EditorField) ValidateValue(ctx context.Context, app App, record *Record
if int64(len(val)) > maxSize {
return validation.NewError(
"validation_content_size_limit",
fmt.Sprintf("The maximum allowed content size is %v bytes", maxSize),
"The maximum allowed content size is {{.maxSize}} bytes",
).SetParams(map[string]any{"maxSize": maxSize})
}
@@ -149,7 +148,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
)
}
+11
View File
@@ -206,6 +206,17 @@ func TestEditorFieldValidateSettings(t *testing.T) {
},
[]string{},
},
{
"MaxSize > safe json int",
func() *core.EditorField {
return &core.EditorField{
Id: "test",
Name: "test",
MaxSize: 1 << 53,
}
},
[]string{"maxSize"},
},
}
for _, s := range scenarios {
+19 -10
View File
@@ -94,7 +94,7 @@ type FileField struct {
// ---
// MaxSize specifies the maximum size of a single uploaded file (in bytes).
// MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
//
// If zero, a default limit of 5MB is applied.
MaxSize int64 `form:"maxSize" json:"maxSize"`
@@ -223,8 +223,8 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.MaxSelect, validation.Min(0)),
validation.Field(&f.MaxSize, validation.Min(0)),
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Thumbs, validation.Each(
validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
validation.Match(filesystem.ThumbSizeRegex),
@@ -250,13 +250,22 @@ func (f *FileField) ValidateValue(ctx context.Context, app App, record *Record)
addedStrings := f.excludeFiles(existingStrings, oldExistingStrings)
if len(addedStrings) > 0 {
return validation.NewError("validation_invalid_file", "Invalid files:"+strings.Join(cast.ToStringSlice(addedStrings), ", ")).
SetParams(map[string]any{"invalidFiles": addedStrings})
invalidFiles := make([]string, len(addedStrings))
for i, invalid := range addedStrings {
invalidStr := cast.ToString(invalid)
if len(invalidStr) > 250 {
invalidStr = invalidStr[:250]
}
invalidFiles[i] = invalidStr
}
return validation.NewError("validation_invalid_file", "Invalid new files: {{.invalidFiles}}.").
SetParams(map[string]any{"invalidFiles": invalidFiles})
}
maxSelect := f.maxSelect()
if len(files) > maxSelect {
return validation.NewError("validation_too_many_files", fmt.Sprintf("The maximum allowed files is %d", maxSelect)).
return validation.NewError("validation_too_many_files", "The maximum allowed files is {{.maxSelect}}").
SetParams(map[string]any{"maxSelect": maxSelect})
}
@@ -391,7 +400,7 @@ func (f *FileField) Intercept(
}
func (f *FileField) getLatestOldValue(app App, record *Record) any {
if !record.IsNew() {
latestOriginal, err := app.FindRecordById(record.Collection(), record.Id)
latestOriginal, err := app.FindRecordById(record.Collection(), cast.ToString(record.LastSavedPK()))
if err == nil {
return latestOriginal.GetRaw(f.Name)
}
@@ -739,14 +748,14 @@ func (f *FileField) toSliceValue(raw any) []any {
}
func (f *FileField) uniqueFiles(files []any) []any {
existing := make(map[string]struct{}, len(files))
found := make(map[string]struct{}, len(files))
result := make([]any, 0, len(files))
for _, fv := range files {
name := f.getFileName(fv)
if _, ok := existing[name]; !ok {
if _, ok := found[name]; !ok {
result = append(result, fv)
existing[name] = struct{}{}
found[name] = struct{}{}
}
}
+44
View File
@@ -539,6 +539,50 @@ func TestFileFieldValidateSettings(t *testing.T) {
},
[]string{},
},
{
"MaxSize > safe json int",
func() *core.FileField {
return &core.FileField{
Id: "test",
Name: "test",
MaxSize: 1 << 53,
}
},
[]string{"maxSize"},
},
{
"MaxSize < 0",
func() *core.FileField {
return &core.FileField{
Id: "test",
Name: "test",
MaxSize: -1,
}
},
[]string{"maxSize"},
},
{
"MaxSelect > safe json int",
func() *core.FileField {
return &core.FileField{
Id: "test",
Name: "test",
MaxSelect: 1 << 53,
}
},
[]string{"maxSelect"},
},
{
"MaxSelect < 0",
func() *core.FileField {
return &core.FileField{
Id: "test",
Name: "test",
MaxSelect: -1,
}
},
[]string{"maxSelect"},
},
}
for _, s := range scenarios {
+3 -4
View File
@@ -2,7 +2,6 @@ package core
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
@@ -52,7 +51,7 @@ type JSONField struct {
// ---
// MaxSize specifies the maximum size of the allowed field value (in bytes).
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
//
// If zero, a default limit of 5MB is applied.
MaxSize int64 `form:"maxSize" json:"maxSize"`
@@ -160,7 +159,7 @@ func (f *JSONField) ValidateValue(ctx context.Context, app App, record *Record)
if int64(len(raw)) > maxSize {
return validation.NewError(
"validation_json_size_limit",
fmt.Sprintf("The maximum allowed JSON size is %v bytes", maxSize),
"The maximum allowed JSON size is {{.maxSize}} bytes",
).SetParams(map[string]any{"maxSize": maxSize})
}
@@ -182,7 +181,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
)
}
+14 -3
View File
@@ -200,7 +200,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
expectErrors []string
}{
{
"< 0 MaxSize",
"MaxSize < 0",
func() *core.JSONField {
return &core.JSONField{
Id: "test",
@@ -211,7 +211,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
[]string{"maxSize"},
},
{
"= 0 MaxSize",
"MaxSize = 0",
func() *core.JSONField {
return &core.JSONField{
Id: "test",
@@ -221,7 +221,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
[]string{},
},
{
"> 0 MaxSize",
"MaxSize > 0",
func() *core.JSONField {
return &core.JSONField{
Id: "test",
@@ -231,6 +231,17 @@ func TestJSONFieldValidateSettings(t *testing.T) {
},
[]string{},
},
{
"MaxSize > safe json int",
func() *core.JSONField {
return &core.JSONField{
Id: "test",
Name: "test",
MaxSize: 1 << 53,
}
},
[]string{"maxSize"},
},
}
for _, s := range scenarios {
+2 -3
View File
@@ -3,7 +3,6 @@ package core
import (
"context"
"database/sql/driver"
"fmt"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
@@ -202,13 +201,13 @@ func (f *RelationField) ValidateValue(ctx context.Context, app App, record *Reco
}
if f.MinSelect > 0 && len(ids) < f.MinSelect {
return validation.NewError("validation_not_enough_values", fmt.Sprintf("Select at least %d", f.MinSelect)).
return validation.NewError("validation_not_enough_values", "Select at least {{.minSelect}}").
SetParams(map[string]any{"minSelect": f.MinSelect})
}
maxSelect := max(f.MaxSelect, 1)
if len(ids) > maxSelect {
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", maxSelect)).
return validation.NewError("validation_too_many_values", "Select no more than {{.maxSelect}}").
SetParams(map[string]any{"maxSelect": maxSelect})
}
+2 -3
View File
@@ -3,7 +3,6 @@ package core
import (
"context"
"database/sql/driver"
"fmt"
"slices"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -192,14 +191,14 @@ func (f *SelectField) ValidateValue(ctx context.Context, app App, record *Record
// check max selected items
if len(normalizedVal) > maxSelect {
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", maxSelect)).
return validation.NewError("validation_too_many_values", "Select no more than {{.maxSelect}}").
SetParams(map[string]any{"maxSelect": maxSelect})
}
// check against the allowed values
for _, val := range normalizedVal {
if !slices.Contains(f.Values, val) {
return validation.NewError("validation_invalid_value", "Invalid value "+val).
return validation.NewError("validation_invalid_value", "Invalid value {{.value}}").
SetParams(map[string]any{"value": val})
}
}
+4 -4
View File
@@ -91,7 +91,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
"invalid length",
func() core.Field {
f := core.Fields[fieldType]()
f.SetId(strings.Repeat("a", 256))
f.SetId(strings.Repeat("a", 101))
return f
},
true,
@@ -100,7 +100,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
"valid length",
func() core.Field {
f := core.Fields[fieldType]()
f.SetId(strings.Repeat("a", 255))
f.SetId(strings.Repeat("a", 100))
return f
},
false,
@@ -142,7 +142,7 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
"invalid length",
func() core.Field {
f := core.Fields[fieldType]()
f.SetName(strings.Repeat("a", 256))
f.SetName(strings.Repeat("a", 101))
return f
},
true,
@@ -151,7 +151,7 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
"valid length",
func() core.Field {
f := core.Fields[fieldType]()
f.SetName(strings.Repeat("a", 255))
f.SetName(strings.Repeat("a", 100))
return f
},
false,
+52 -14
View File
@@ -2,10 +2,14 @@ package core
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
@@ -151,6 +155,8 @@ func (f *TextField) PrepareValue(record *Record, raw any) (any, error) {
return cast.ToString(raw), nil
}
var forbiddenPKChars = []string{"/", "\\"}
// ValidateValue implements [Field.ValidateValue] interface method.
func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record) error {
newVal, ok := record.GetRaw(f.Name).(string)
@@ -158,14 +164,46 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
return validators.ErrUnsupportedValueType
}
// disallow PK change
if f.PrimaryKey && !record.IsNew() {
oldVal := record.LastSavedPK()
if oldVal != newVal {
return validation.NewError("validation_pk_change", "The record primary key cannot be changed.")
}
if oldVal != "" {
return nil // no need to further validate since the id can't be updated anyway
if f.PrimaryKey {
// disallow PK change
if !record.IsNew() {
oldVal := record.LastSavedPK()
if oldVal != newVal {
return validation.NewError("validation_pk_change", "The record primary key cannot be changed.")
}
if oldVal != "" {
// no need to further validate because the id can't be updated
// and because the id could have been inserted manually by migration from another system
// that may not comply with the user defined PocketBase validations
return nil
}
} else {
// disallow PK special characters no matter of the Pattern validator to minimize
// side-effects when the primary key is used for example in a directory path
for _, c := range forbiddenPKChars {
if strings.Contains(newVal, c) {
return validation.NewError("validation_pk_forbidden", "The record primary key contains forbidden characters.").
SetParams(map[string]any{"forbidden": c})
}
}
// this technically shouldn't be necessarily but again to
// minimize misuse of the Pattern validator that could cause
// side-effects on some platforms check for duplicates in a case-insensitive manner
//
// (@todo eventually may get replaced in the future with a system unique constraint to avoid races or wrapping the request in a transaction)
if f.Pattern != defaultLowercaseRecordIdPattern {
var exists bool
err := app.DB().
Select("(1)").
From(record.TableName()).
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": strings.ToLower(newVal)})).
Limit(1).
Row(&exists)
if exists || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
}
}
}
}
@@ -188,7 +226,7 @@ func (f *TextField) ValidatePlainValue(value string) error {
length := len([]rune(value))
if f.Min > 0 && length < f.Min {
return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", f.Min)).
return validation.NewError("validation_min_text_constraint", "Must be at least {{.min}} character(s)").
SetParams(map[string]any{"min": f.Min})
}
@@ -198,8 +236,8 @@ func (f *TextField) ValidatePlainValue(value string) error {
}
if max > 0 && length > max {
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", max)).
SetParams(map[string]any{"max": f.Max})
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s)").
SetParams(map[string]any{"max": max})
}
if f.Pattern != "" {
@@ -221,9 +259,9 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)),
),
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
validation.Field(&f.Min, validation.Min(0)),
validation.Field(&f.Max, validation.Min(f.Min)),
validation.Field(&f.Pattern, validation.By(validators.IsRegex)),
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Pattern, validation.When(f.PrimaryKey, validation.Required), validation.By(validators.IsRegex)),
validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
validation.Field(&f.AutogeneratePattern, validation.By(validators.IsRegex), validation.By(f.checkAutogeneratePattern)),
+120 -2
View File
@@ -68,7 +68,15 @@ func TestTextFieldValidateValue(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewBaseCollection("test_collection")
collection, err := app.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
existingRecord, err := app.FindFirstRecordByFilter(collection, "id != ''")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
@@ -116,6 +124,46 @@ func TestTextFieldValidateValue(t *testing.T) {
},
false,
},
{
"special forbidden character / (non-primaryKey)",
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "/")
return record
},
false,
},
{
"special forbidden character \\ (non-primaryKey)",
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "\\")
return record
},
false,
},
{
"special forbidden character / (primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "/")
return record
},
true,
},
{
"special forbidden character \\ (primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "\\")
return record
},
true,
},
{
"zero field value (primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
@@ -131,11 +179,21 @@ func TestTextFieldValidateValue(t *testing.T) {
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "abc")
record.SetRaw("test", "abcd")
return record
},
false,
},
{
"case-insensitive duplicated primary key check",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", strings.ToUpper(existingRecord.Id))
return record
},
true,
},
{
"< min",
&core.TextField{Name: "test", Min: 4},
@@ -259,10 +317,23 @@ func TestTextFieldValidateSettings(t *testing.T) {
Id: "test",
Name: "id",
PrimaryKey: true,
Pattern: `\d+`,
}
},
[]string{"required"},
},
{
"primaryKey without pattern",
func() *core.TextField {
return &core.TextField{
Id: "test",
Name: "id",
PrimaryKey: true,
Required: true,
}
},
[]string{"pattern"},
},
{
"primaryKey with hidden",
func() *core.TextField {
@@ -272,6 +343,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
Required: true,
PrimaryKey: true,
Hidden: true,
Pattern: `\d+`,
}
},
[]string{"hidden"},
@@ -284,6 +356,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
Name: "test",
PrimaryKey: true,
Required: true,
Pattern: `\d+`,
}
},
[]string{"name"},
@@ -295,6 +368,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
Id: "test2",
Name: "id",
PrimaryKey: true,
Pattern: `\d+`,
Required: true,
}
},
@@ -356,6 +430,50 @@ func TestTextFieldValidateSettings(t *testing.T) {
},
[]string{"autogeneratePattern"},
},
{
"Max > safe json int",
func() *core.TextField {
return &core.TextField{
Id: "test",
Name: "test",
Max: 1 << 53,
}
},
[]string{"max"},
},
{
"Max < 0",
func() *core.TextField {
return &core.TextField{
Id: "test",
Name: "test",
Max: -1,
}
},
[]string{"max"},
},
{
"Min > safe json int",
func() *core.TextField {
return &core.TextField{
Id: "test",
Name: "test",
Min: 1 << 53,
}
},
[]string{"min"},
},
{
"Min < 0",
func() *core.TextField {
return &core.TextField{
Id: "test",
Name: "test",
Min: -1,
}
},
[]string{"min"},
},
}
for _, s := range scenarios {
+92 -19
View File
@@ -4,6 +4,7 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"slices"
"strconv"
)
@@ -12,7 +13,7 @@ func NewFieldsList(fields ...Field) FieldsList {
l := make(FieldsList, 0, len(fields))
for _, f := range fields {
l.Add(f)
l.add(-1, f)
}
return l
@@ -116,7 +117,26 @@ func (l *FieldsList) RemoveByName(fieldName string) {
// (the id value doesn't really matter and it is mostly used as a stable identifier in case of a field rename).
func (l *FieldsList) Add(fields ...Field) {
for _, f := range fields {
l.add(f)
l.add(-1, f)
}
}
// AddAt is the same as Add but insert/move the fields at the specific position.
//
// If pos < 0, then this method acts the same as calling Add.
//
// If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list.
func (l *FieldsList) AddAt(pos int, fields ...Field) {
total := len(*l)
for i, f := range fields {
if pos < 0 {
l.add(-1, f)
} else if pos > total {
l.add(total+i, f)
} else {
l.add(pos+i, f)
}
}
}
@@ -132,13 +152,42 @@ func (l *FieldsList) Add(fields ...Field) {
// l.AddMarshaledJSON([]byte{`{"type":"text", name: "test"}`})
// l.AddMarshaledJSON([]byte{`[{"type":"text", name: "test1"}, {"type":"text", name: "test2"}]`})
func (l *FieldsList) AddMarshaledJSON(rawJSON []byte) error {
extractedFields, err := marshaledJSONtoFieldsList(rawJSON)
if err != nil {
return err
}
l.Add(extractedFields...)
return nil
}
// AddMarshaledJSONAt is the same as AddMarshaledJSON but insert/move the fields at the specific position.
//
// If pos < 0, then this method acts the same as calling AddMarshaledJSON.
//
// If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list.
func (l *FieldsList) AddMarshaledJSONAt(pos int, rawJSON []byte) error {
extractedFields, err := marshaledJSONtoFieldsList(rawJSON)
if err != nil {
return err
}
l.AddAt(pos, extractedFields...)
return nil
}
func marshaledJSONtoFieldsList(rawJSON []byte) (FieldsList, error) {
extractedFields := FieldsList{}
// nothing to add
if len(rawJSON) == 0 {
return nil // nothing to add
return extractedFields, nil
}
// try to unmarshal first into a new fieds list
// (assuming that rawJSON is array of objects)
extractedFields := FieldsList{}
err := json.Unmarshal(rawJSON, &extractedFields)
if err != nil {
// try again but wrap the rawJSON in []
@@ -149,21 +198,25 @@ func (l *FieldsList) AddMarshaledJSON(rawJSON []byte) error {
wrapped = append(wrapped, ']')
err = json.Unmarshal(wrapped, &extractedFields)
if err != nil {
return fmt.Errorf("failed to unmarshal the provided JSON - expects array of objects or just single object: %w", err)
return nil, fmt.Errorf("failed to unmarshal the provided JSON - expects array of objects or just single object: %w", err)
}
}
for _, f := range extractedFields {
l.add(f)
}
return nil
return extractedFields, nil
}
func (l *FieldsList) add(newField Field) {
func (l *FieldsList) add(pos int, newField Field) {
fields := *l
var replaceByName bool
var replaceInPlace bool
if pos < 0 {
replaceInPlace = true
pos = len(fields)
} else if pos > len(fields) {
pos = len(fields)
}
newFieldId := newField.GetId()
@@ -182,24 +235,44 @@ func (l *FieldsList) add(newField Field) {
newField.SetId(newFieldId)
}
// replace existing
// try to replace existing
for i, field := range fields {
if replaceByName {
if name := newField.GetName(); name != "" && field.GetName() == name {
// reuse the original id
newField.SetId(field.GetId())
(*l)[i] = newField
return
if replaceInPlace {
(*l)[i] = newField
return
} else {
// remove the current field and insert it later at the specific position
*l = slices.Delete(*l, i, i+1)
if total := len(*l); pos > total {
pos = total
}
break
}
}
} else {
if field.GetId() == newFieldId {
(*l)[i] = newField
return
if replaceInPlace {
(*l)[i] = newField
return
} else {
// remove the current field and insert it later at the specific position
*l = slices.Delete(*l, i, i+1)
if total := len(*l); pos > total {
pos = total
}
break
}
}
}
}
// add new field
*l = append(fields, newField)
// insert the new field
*l = slices.Insert(*l, pos, newField)
}
// String returns the string representation of the current list.
@@ -252,7 +325,7 @@ func (l *FieldsList) UnmarshalJSON(data []byte) error {
*l = []Field{} // reset
for _, fwt := range fwts {
l.Add(fwt.Field)
l.add(-1, fwt.Field)
}
return nil
+93
View File
@@ -1,7 +1,10 @@
package core_test
import (
"bytes"
"encoding/json"
"slices"
"strconv"
"strings"
"testing"
@@ -301,6 +304,96 @@ func TestFieldsListAddMarshaledJSON(t *testing.T) {
}
}
func TestFieldsListAddAt(t *testing.T) {
scenarios := []struct {
position int
expected []string
}{
{-2, []string{"test1", "test2_new", "test3", "test4"}},
{-1, []string{"test1", "test2_new", "test3", "test4"}},
{0, []string{"test2_new", "test4", "test1", "test3"}},
{1, []string{"test1", "test2_new", "test4", "test3"}},
{2, []string{"test1", "test3", "test2_new", "test4"}},
{3, []string{"test1", "test3", "test2_new", "test4"}},
{4, []string{"test1", "test3", "test2_new", "test4"}},
{5, []string{"test1", "test3", "test2_new", "test4"}},
}
for _, s := range scenarios {
t.Run(strconv.Itoa(s.position), func(t *testing.T) {
f1 := &core.TextField{Id: "f1Id", Name: "test1"}
f2 := &core.TextField{Id: "f2Id", Name: "test2"}
f3 := &core.TextField{Id: "f3Id", Name: "test3"}
testFieldsList := core.NewFieldsList(f1, f2, f3)
f2New := &core.EmailField{Id: "f2Id", Name: "test2_new"}
f4 := &core.URLField{Name: "test4"}
testFieldsList.AddAt(s.position, f2New, f4)
rawNames, err := json.Marshal(testFieldsList.FieldNames())
if err != nil {
t.Fatal(err)
}
rawExpected, err := json.Marshal(s.expected)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(rawNames, rawExpected) {
t.Fatalf("Expected fields\n%s\ngot\n%s", rawExpected, rawNames)
}
})
}
}
func TestFieldsListAddMarshaledJSONAt(t *testing.T) {
scenarios := []struct {
position int
expected []string
}{
{-2, []string{"test1", "test2_new", "test3", "test4"}},
{-1, []string{"test1", "test2_new", "test3", "test4"}},
{0, []string{"test2_new", "test4", "test1", "test3"}},
{1, []string{"test1", "test2_new", "test4", "test3"}},
{2, []string{"test1", "test3", "test2_new", "test4"}},
{3, []string{"test1", "test3", "test2_new", "test4"}},
{4, []string{"test1", "test3", "test2_new", "test4"}},
{5, []string{"test1", "test3", "test2_new", "test4"}},
}
for _, s := range scenarios {
t.Run(strconv.Itoa(s.position), func(t *testing.T) {
f1 := &core.TextField{Id: "f1Id", Name: "test1"}
f2 := &core.TextField{Id: "f2Id", Name: "test2"}
f3 := &core.TextField{Id: "f3Id", Name: "test3"}
testFieldsList := core.NewFieldsList(f1, f2, f3)
err := testFieldsList.AddMarshaledJSONAt(s.position, []byte(`[
{"id":"f2Id", "name":"test2_new", "type": "text"},
{"name": "test4", "type": "text"}
]`))
if err != nil {
t.Fatal(err)
}
rawNames, err := json.Marshal(testFieldsList.FieldNames())
if err != nil {
t.Fatal(err)
}
rawExpected, err := json.Marshal(s.expected)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(rawNames, rawExpected) {
t.Fatalf("Expected fields\n%s\ngot\n%s", rawExpected, rawNames)
}
})
}
}
func TestFieldsListStringAndValue(t *testing.T) {
t.Run("empty list", func(t *testing.T) {
testFieldsList := core.NewFieldsList()
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/spf13/cast"
)
var cachedColors = store.New[*color.Color](nil)
var cachedColors = store.New[string, *color.Color](nil)
// getColor returns [color.Color] object and cache it (if not already).
func getColor(attrs ...color.Attribute) (c *color.Color) {
+1 -1
View File
@@ -122,7 +122,7 @@ func (app *BaseApp) registerMFAHooks() {
recordRefHooks[*MFA](app, CollectionNameMFAs, CollectionTypeAuth)
// run on every hour to cleanup expired mfa sessions
app.Cron().Add("__mfasCleanup__", "0 * * * *", func() {
app.Cron().Add("__pbMFACleanup__", "0 * * * *", func() {
if err := app.DeleteExpiredMFAs(); err != nil {
app.Logger().Warn("Failed to delete expired MFA sessions", "error", err)
}
+15 -1
View File
@@ -85,6 +85,20 @@ func (m *OTP) SetRecordRef(recordId string) {
m.Set("recordRef", recordId)
}
// SentTo returns the "sentTo" record field value.
//
// It could be any string value (email, phone, message app id, etc.)
// and usually is used as part of the auth flow to update the verified
// user state in case for example the sentTo value matches with the user record email.
func (m *OTP) SentTo() string {
return m.GetString("sentTo")
}
// SetSentTo updates the "sentTo" record field value.
func (m *OTP) SetSentTo(val string) {
m.Set("sentTo", val)
}
// Created returns the "created" record field value.
func (m *OTP) Created() types.DateTime {
return m.GetDateTime("created")
@@ -105,7 +119,7 @@ func (app *BaseApp) registerOTPHooks() {
recordRefHooks[*OTP](app, CollectionNameOTPs, CollectionTypeAuth)
// run on every hour to cleanup expired otp sessions
app.Cron().Add("__otpsCleanup__", "0 * * * *", func() {
app.Cron().Add("__pbOTPCleanup__", "0 * * * *", func() {
if err := app.DeleteExpiredOTPs(); err != nil {
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
}
+24
View File
@@ -85,6 +85,30 @@ func TestOTPCollectionRef(t *testing.T) {
}
}
func TestOTPSentTo(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
otp := core.NewOTP(app)
testValues := []string{"test_1", "test2", ""}
for i, testValue := range testValues {
t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
otp.SetSentTo(testValue)
if v := otp.SentTo(); v != testValue {
t.Fatalf("Expected getter %q, got %q", testValue, v)
}
if v := otp.GetString("sentTo"); v != testValue {
t.Fatalf("Expected field value %q, got %q", testValue, v)
}
})
}
}
func TestOTPCreated(t *testing.T) {
t.Parallel()
+32 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
@@ -18,6 +19,7 @@ const (
eachModifier string = "each"
issetModifier string = "isset"
lengthModifier string = "length"
lowerModifier string = "lower"
)
// ensure that `search.FieldResolver` interface is implemented
@@ -47,6 +49,26 @@ type RecordFieldResolver struct {
allowHiddenFields bool
}
// AllowedFields returns a copy of the resolver's allowed fields.
func (r *RecordFieldResolver) AllowedFields() []string {
return slices.Clone(r.allowedFields)
}
// SetAllowedFields replaces the resolver's allowed fields with the new ones.
func (r *RecordFieldResolver) SetAllowedFields(newAllowedFields []string) {
r.allowedFields = slices.Clone(newAllowedFields)
}
// AllowHiddenFields returns whether the current resolver allows filtering hidden fields.
func (r *RecordFieldResolver) AllowHiddenFields() bool {
return r.allowHiddenFields
}
// SetAllowHiddenFields enables or disables hidden fields filtering.
func (r *RecordFieldResolver) SetAllowHiddenFields(allowHiddenFields bool) {
r.allowHiddenFields = allowHiddenFields
}
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
func NewRecordFieldResolver(
app App,
@@ -193,7 +215,14 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
resultVal = val
}
placeholder := "f" + security.PseudorandomString(6)
placeholder := "f" + security.PseudorandomString(8)
if modifier == lowerModifier {
return &search.ResolverResult{
Identifier: "LOWER({:" + placeholder + "})",
Params: dbx.Params{placeholder: resultVal},
}, nil
}
return &search.ResolverResult{
Identifier: "{:" + placeholder + "}",
@@ -355,7 +384,8 @@ func splitModifier(combined string) (string, string, error) {
switch parts[1] {
case issetModifier,
eachModifier,
lengthModifier:
lengthModifier,
lowerModifier:
return parts[0], parts[1], nil
}
+41 -6
View File
@@ -15,6 +15,7 @@ import (
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
// maxNestedRels defines the max allowed nested relations depth.
@@ -113,6 +114,11 @@ func (r *runner) run() (*search.ResolverResult, error) {
if modifier == lengthModifier && len(r.activeProps) == 3 {
return r.processRequestInfoLengthModifier(bodyField)
}
// check for body arrayble fields ":lower" modifier
if modifier == lowerModifier && len(r.activeProps) == 3 {
return r.processRequestInfoLowerModifier(bodyField)
}
}
// some other @request.* static field
@@ -191,6 +197,10 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
}
func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
// plain auth field
// ---
if _, ok := plainRequestAuthFields[r.fieldName]; ok {
@@ -199,10 +209,6 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
// resolve the auth collection field
// ---
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
collection := r.resolver.requestInfo.Auth.Collection()
r.activeCollectionName = collection.Name
@@ -262,6 +268,19 @@ func toSlice(value any) []any {
return result
}
func (r *runner) processRequestInfoLowerModifier(bodyField Field) (*search.ResolverResult, error) {
rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()])
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(6)
result := &search.ResolverResult{
Identifier: "LOWER({:" + placeholder + "})",
Params: dbx.Params{placeholder: rawValue},
}
return result, nil
}
func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.ResolverResult, error) {
if _, ok := bodyField.(MultiValuer); !ok {
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
@@ -396,6 +415,10 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
field := collection.Fields.GetByName(prop)
if field != nil && field.GetHidden() && !r.allowHiddenFields {
return nil, fmt.Errorf("non-filterable field %q", prop)
}
// json field -> treat the rest of the props as json path
if field != nil && field.Type() == FieldTypeJSON {
var jsonPath strings.Builder
@@ -460,6 +483,10 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
}
if backField.GetHidden() && !r.allowHiddenFields {
return nil, fmt.Errorf("non-filterable back relation field %q", backField.GetName())
}
backRelField, ok := backField.(*RelationField)
if !ok {
return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
@@ -713,11 +740,11 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
// default
// -------------------------------------------------------
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, cleanFieldName),
Identifier: "[[" + r.activeTableAlias + "." + cleanFieldName + "]]",
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, cleanFieldName)
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
result.MultiMatchSubQuery = r.multiMatch
}
@@ -743,5 +770,13 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
}
}
// account for the ":lower" modifier
if modifier == lowerModifier {
result.Identifier = "LOWER(" + result.Identifier + ")"
if r.withMultiMatch {
r.multiMatch.valueIdentifier = "LOWER(" + r.multiMatch.valueIdentifier + ")"
}
}
return result, nil
}
+120 -13
View File
@@ -3,6 +3,7 @@ package core_test
import (
"encoding/json"
"regexp"
"slices"
"strings"
"testing"
@@ -12,7 +13,75 @@ import (
"github.com/pocketbase/pocketbase/tools/search"
)
func TestRecordFieldResolverAllowedFields(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
r := core.NewRecordFieldResolver(app, collection, nil, false)
fields := r.AllowedFields()
if len(fields) != 8 {
t.Fatalf("Expected %d original allowed fields, got %d", 8, len(fields))
}
// change the allowed fields
newFields := []string{"a", "b", "c"}
expected := slices.Clone(newFields)
r.SetAllowedFields(newFields)
// change the new fields to ensure that the slice was cloned
newFields[2] = "d"
fields = r.AllowedFields()
if len(fields) != len(expected) {
t.Fatalf("Expected %d changed allowed fields, got %d", len(expected), len(fields))
}
for i, v := range expected {
if fields[i] != v {
t.Errorf("[%d] Expected field %q", i, v)
}
}
}
func TestRecordFieldResolverAllowHiddenFields(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
r := core.NewRecordFieldResolver(app, collection, nil, false)
allowHiddenFields := r.AllowHiddenFields()
if allowHiddenFields {
t.Fatalf("Expected original allowHiddenFields %v, got %v", allowHiddenFields, !allowHiddenFields)
}
// change the flag
expected := !allowHiddenFields
r.SetAllowHiddenFields(expected)
allowHiddenFields = r.AllowHiddenFields()
if allowHiddenFields != expected {
t.Fatalf("Expected changed allowHiddenFields %v, got %v", expected, allowHiddenFields)
}
}
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -138,7 +207,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4",
"self_rel_many.self_rel_one > true",
false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] WHERE ((([[demo4_self_rel_many.self_rel_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many.self_rel_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))))",
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] WHERE ((([[demo4_self_rel_many.self_rel_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many.self_rel_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
},
{
"nested complete relations (opt/any operator)",
@@ -152,7 +221,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4",
"self_rel_many.self_rel_one.title > true",
false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))))",
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
},
{
"repeated nested relations (opt/any operator)",
@@ -166,7 +235,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4",
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.title > true",
false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many` ON [[demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))))",
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many` ON [[demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
},
{
"multiple relations (opt/any operators)",
@@ -236,7 +305,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4",
"@collection.demo1.text > true || @collection.demo2.active > true || @collection.demo1.file_one > true",
false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2` WHERE ((([[__collection_demo1.text]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.text]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))) OR (([[__collection_demo2.active]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))) OR (([[__collection_demo1.file_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.file_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))))",
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2` WHERE ((([[__collection_demo1.text]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.text]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR (([[__collection_demo2.active]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR (([[__collection_demo1.file_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.file_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
},
{
"@request.auth fields",
@@ -257,14 +326,14 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4",
"@collection.users.email > true || @request.auth.email > true",
false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__collection_users` WHERE ((([[__collection_users.email]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_users.email]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `users` `__mm__collection_users` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) OR {:TEST} > 1)",
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__collection_users` WHERE ((([[__collection_users.email]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_users.email]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `users` `__mm__collection_users` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR {:TEST} > 1)",
},
{
"hidden field (add emailVisibility)",
"users",
"id > true || email > true",
"id > true || email > true || email:lower > false",
false,
"SELECT `users`.* FROM `users` WHERE ([[users.id]] > 1 OR (([[users.email]] > 1) AND ([[users.emailVisibility]] = TRUE)))",
"SELECT `users`.* FROM `users` WHERE ([[users.id]] > 1 OR (([[users.email]] > 1) AND ([[users.emailVisibility]] = TRUE)) OR ((LOWER([[users.email]]) > 0) AND ([[users.emailVisibility]] = TRUE)))",
},
{
"hidden field (force ignore emailVisibility)",
@@ -274,7 +343,45 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"SELECT `users`.* FROM `users` WHERE [[users.email]] > 1",
},
{
"isset key",
"static @request fields with :lower modifier",
"demo1",
"@request.body.a:lower > true ||" +
"@request.body.b:lower > true ||" +
"@request.body.c:lower > true ||" +
"@request.query.a:lower > true ||" +
"@request.query.b:lower > true ||" +
"@request.query.c:lower > true ||" +
"@request.headers.a:lower > true ||" +
"@request.headers.c:lower > true",
false,
"SELECT `demo1`.* FROM `demo1` WHERE (NULL > 1 OR LOWER({:TEST}) > 1 OR NULL > 1 OR LOWER({:TEST}) > 1 OR LOWER({:TEST}) > 1 OR NULL > 1 OR LOWER({:TEST}) > 1 OR NULL > 1)",
},
{
"collection fields with :lower modifier",
"demo1",
"@request.body.rel_one:lower > true ||" +
"@request.body.rel_many:lower > true ||" +
"@request.body.rel_many.email:lower > true ||" +
"text:lower > true ||" +
"bool:lower > true ||" +
"url:lower > true ||" +
"select_one:lower > true ||" +
"select_many:lower > true ||" +
"file_one:lower > true ||" +
"file_many:lower > true ||" +
"number:lower > true ||" +
"email:lower > true ||" +
"datetime:lower > true ||" +
"json:lower > true ||" +
"rel_one:lower > true ||" +
"rel_many:lower > true ||" +
"rel_many.name:lower > true ||" +
"created:lower > true",
false,
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[demo1_rel_many_je.value]] WHERE (LOWER({:infoLowerrel_oneTEST}) > 1 OR LOWER({:infoLowerrel_manyTEST}) > 1 OR ((LOWER([[__data_users_rel_many.email]]) > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT LOWER([[__data_mm_users_rel_many.email]]) as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users_rel_many` ON [[__data_mm_users_rel_many.id]] IN ({:p4}, {:p5}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR LOWER([[demo1.text]]) > 1 OR LOWER([[demo1.bool]]) > 1 OR LOWER([[demo1.url]]) > 1 OR LOWER([[demo1.select_one]]) > 1 OR LOWER([[demo1.select_many]]) > 1 OR LOWER([[demo1.file_one]]) > 1 OR LOWER([[demo1.file_many]]) > 1 OR LOWER([[demo1.number]]) > 1 OR LOWER([[demo1.email]]) > 1 OR LOWER([[demo1.datetime]]) > 1 OR LOWER((CASE WHEN json_valid([[demo1.json]]) THEN JSON_EXTRACT([[demo1.json]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo1.json]]), '$.pb') END)) > 1 OR LOWER([[demo1.rel_one]]) > 1 OR LOWER([[demo1.rel_many]]) > 1 OR ((LOWER([[demo1_rel_many.name]]) > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT LOWER([[__mm_demo1_rel_many.name]]) as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR LOWER([[demo1.created]]) > 1)",
},
{
"isset modifier",
"demo1",
"@request.body.a:isset > true ||" +
"@request.body.b:isset > true ||" +
@@ -314,7 +421,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"@request.body.rel_many:each > true &&" +
"@request.body.rel_many:each ?< true",
false,
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_many_je` WHERE ([[__dataEach_select_one_je.value]] > 1 AND [[__dataEach_select_one_je.value]] < 1 AND (([[__dataEach_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[__dataEach_select_many_je.value]] < 1 AND [[__dataEach_file_one_je.value]] > 1 AND [[__dataEach_file_one_je.value]] < 1 AND (([[__dataEach_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[__dataEach_file_many_je.value]] < 1 AND [[__dataEach_rel_one_je.value]] > 1 AND [[__dataEach_rel_one_je.value]] < 1 AND (([[__dataEach_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[__dataEach_rel_many_je.value]] < 1)",
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_many_je` WHERE ([[__dataEach_select_one_je.value]] > 1 AND [[__dataEach_select_one_je.value]] < 1 AND (([[__dataEach_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_select_many_je.value]] < 1 AND [[__dataEach_file_one_je.value]] > 1 AND [[__dataEach_file_one_je.value]] < 1 AND (([[__dataEach_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_file_many_je.value]] < 1 AND [[__dataEach_rel_one_je.value]] > 1 AND [[__dataEach_rel_one_je.value]] < 1 AND (([[__dataEach_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_rel_many_je.value]] < 1)",
},
{
"regular arrayble:each fields",
@@ -332,7 +439,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"rel_many:each > true &&" +
"rel_many:each ?< true",
false,
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_one]]) THEN [[demo1.file_one]] ELSE json_array([[demo1.file_one]]) END) `demo1_file_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_many]]) THEN [[demo1.file_many]] ELSE json_array([[demo1.file_many]]) END) `demo1_file_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END) `demo1_rel_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` WHERE ([[demo1_select_one_je.value]] > 1 AND [[demo1_select_one_je.value]] < 1 AND (([[demo1_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_select_many_je.value]] < 1 AND [[demo1_file_one_je.value]] > 1 AND [[demo1_file_one_je.value]] < 1 AND (([[demo1_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.file_many]]) THEN [[__mm_demo1.file_many]] ELSE json_array([[__mm_demo1.file_many]]) END) `__mm_demo1_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_file_many_je.value]] < 1 AND [[demo1_rel_one_je.value]] > 1 AND [[demo1_rel_one_je.value]] < 1 AND (([[demo1_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_rel_many_je.value]] < 1)",
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_one]]) THEN [[demo1.file_one]] ELSE json_array([[demo1.file_one]]) END) `demo1_file_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_many]]) THEN [[demo1.file_many]] ELSE json_array([[demo1.file_many]]) END) `demo1_file_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END) `demo1_rel_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` WHERE ([[demo1_select_one_je.value]] > 1 AND [[demo1_select_one_je.value]] < 1 AND (([[demo1_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[demo1_select_many_je.value]] < 1 AND [[demo1_file_one_je.value]] > 1 AND [[demo1_file_one_je.value]] < 1 AND (([[demo1_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.file_many]]) THEN [[__mm_demo1.file_many]] ELSE json_array([[__mm_demo1.file_many]]) END) `__mm_demo1_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[demo1_file_many_je.value]] < 1 AND [[demo1_rel_one_je.value]] > 1 AND [[demo1_rel_one_je.value]] < 1 AND (([[demo1_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[demo1_rel_many_je.value]] < 1)",
},
{
"arrayble:each vs arrayble:each",
@@ -342,7 +449,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"select_many:each ?< select_one:each &&" +
"select_many:each = @request.body.select_many:each",
false,
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` WHERE (((COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[demo1_select_many_je.value]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT (COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[__smTEST.multiMatchValue]], ''))) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND (([[demo1_select_many_je.value]] > [[demo1_select_one_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > [[demo1_select_one_je.value]])) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_select_many_je.value]] < [[demo1_select_one_je.value]] AND (([[demo1_select_many_je.value]] = [[__dataEach_select_many_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))))",
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` WHERE (((COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[demo1_select_many_je.value]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT (COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[__smTEST.multiMatchValue]], ''))))) AND (([[demo1_select_many_je.value]] > [[demo1_select_one_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > [[demo1_select_one_je.value]])))) AND [[demo1_select_many_je.value]] < [[demo1_select_one_je.value]] AND (([[demo1_select_many_je.value]] = [[__dataEach_select_many_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))))",
},
{
"mixed multi-match vs multi-match",
@@ -354,7 +461,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"@collection.demo2.active ?= rel_many.rel.active &&" +
"rel_many.email > @request.body.rel_many.email",
false,
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[demo1_rel_many_je.value]] LEFT JOIN `demo2` `demo1_rel_many_rel` ON [[demo1_rel_many_rel.id]] = [[demo1_rel_many.rel]] LEFT JOIN `demo1` `demo1_rel_one` ON [[demo1_rel_one.id]] = [[demo1.rel_one]] LEFT JOIN `demo2` `__collection_demo2` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) WHERE (((COALESCE([[demo1_rel_many_rel.active]], '') IS NOT COALESCE([[demo1_rel_many.name]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many.name]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE ((NOT (COALESCE([[__mlTEST.multiMatchValue]], '') IS NOT COALESCE([[__mrTEST.multiMatchValue]], ''))) OR ([[__mlTEST.multiMatchValue]] IS NULL) OR ([[__mrTEST.multiMatchValue]] IS NULL))))) AND COALESCE([[demo1_rel_many_rel.active]], '') = COALESCE([[demo1_rel_many.name]], '') AND (([[demo1_rel_many_rel.title]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\') AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.title]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\')) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND ((COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '') AND (((([[demo1_rel_many.email]] > [[__data_users_rel_many.email]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__data_mm_users_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users_rel_many` ON [[__data_mm_users_rel_many.id]] IN ({:p2}, {:p3}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE ((NOT ([[__mlTEST.multiMatchValue]] > [[__mrTEST.multiMatchValue]])) OR ([[__mlTEST.multiMatchValue]] IS NULL) OR ([[__mrTEST.multiMatchValue]] IS NULL)))))) AND ([[demo1_rel_many.emailVisibility]] = TRUE)))",
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[demo1_rel_many_je.value]] LEFT JOIN `demo2` `demo1_rel_many_rel` ON [[demo1_rel_many_rel.id]] = [[demo1_rel_many.rel]] LEFT JOIN `demo1` `demo1_rel_one` ON [[demo1_rel_one.id]] = [[demo1.rel_one]] LEFT JOIN `demo2` `__collection_demo2` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) WHERE (((COALESCE([[demo1_rel_many_rel.active]], '') IS NOT COALESCE([[demo1_rel_many.name]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many.name]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') IS NOT COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[demo1_rel_many_rel.active]], '') = COALESCE([[demo1_rel_many.name]], '') AND (([[demo1_rel_many_rel.title]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\') AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.title]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\')))) AND ((COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '') AND (((([[demo1_rel_many.email]] > [[__data_users_rel_many.email]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__data_mm_users_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users_rel_many` ON [[__data_mm_users_rel_many.id]] IN ({:p2}, {:p3}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT ([[__mlTEST.multiMatchValue]] > [[__mrTEST.multiMatchValue]]))))) AND ([[demo1_rel_many.emailVisibility]] = TRUE)))",
},
{
"@request.body.arrayable:length fields",
@@ -387,7 +494,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"self_rel_one.rel_many_cascade.files:length != 7 &&" +
"self_rel_one.rel_many_cascade.files:length ?!= 8",
false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4_self_rel_one` ON [[__data_demo4_self_rel_one.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_many_cascade` ON [[__data_demo3_rel_many_cascade.id]] IN ({:p1}, {:p2}) LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p3} LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_one.rel_many_cascade]]) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[demo4_self_rel_one_rel_many_cascade_je.value]] WHERE (json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST} AND ((json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__data_mm_demo3_rel_many_cascade.files]]) THEN [[__data_mm_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_mm_demo3_rel_many_cascade.files]] = '' OR [[__data_mm_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_mm_demo3_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__data_mm_demo3_rel_many_cascade` ON [[__data_mm_demo3_rel_many_cascade.id]] IN ({:p8}, {:p9}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] < {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_one_cascade.files]]) THEN [[__data_demo3_rel_one_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_one_cascade.files]] = '' OR [[__data_demo3_rel_one_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_one_cascade.files]]) END) END) < {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND ((json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) IS NOT {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) THEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[__mm_demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm_demo4_self_rel_one` ON [[__mm_demo4_self_rel_one.id]] = [[__mm_demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]) THEN [[__mm_demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[__mm_demo4_self_rel_one.rel_many_Line truncated
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4_self_rel_one` ON [[__data_demo4_self_rel_one.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_many_cascade` ON [[__data_demo3_rel_many_cascade.id]] IN ({:p1}, {:p2}) LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p3} LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_one.rel_many_cascade]]) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[demo4_self_rel_one_rel_many_cascade_je.value]] WHERE (json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST} AND ((json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__data_mm_demo3_rel_many_cascade.files]]) THEN [[__data_mm_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_mm_demo3_rel_many_cascade.files]] = '' OR [[__data_mm_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_mm_demo3_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__data_mm_demo3_rel_many_cascade` ON [[__data_mm_demo3_rel_many_cascade.id]] IN ({:p8}, {:p9}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] < {:TEST})))) AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_one_cascade.files]]) THEN [[__data_demo3_rel_one_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_one_cascade.files]] = '' OR [[__data_demo3_rel_one_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_one_cascade.files]]) END) END) < {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND ((json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) IS NOT {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) THEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[__mm_demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm_demo4_self_rel_one` ON [[__mm_demo4_self_rel_one.id]] = [[__mm_demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]) THEN [[__mm_demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[__mm_demo4_self_rel_one.rel_many_cascade]]) END) `__mm_demo4_self_rel_one_rel_mLine truncated
},
{
"json_extract and json_array_length COALESCE equal normalizations",
+107 -38
View File
@@ -37,9 +37,9 @@ var (
type Record struct {
collection *Collection
originalData map[string]any
customVisibility *store.Store[bool]
data *store.Store[any]
expand *store.Store[any]
customVisibility *store.Store[string, bool]
data *store.Store[string, any]
expand *store.Store[string, any]
BaseModel
@@ -55,10 +55,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -70,10 +73,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -85,10 +91,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -100,10 +109,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -115,10 +127,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelErrorEvent) error {
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
return me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
err := me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
syncModelErrorEventWithRecordErrorEvent(me, re)
defer syncRecordErrorEventWithModelErrorEvent(re, me)
return me.Next()
})
syncModelErrorEventWithRecordErrorEvent(me, re)
return err
}
return me.Next()
@@ -130,10 +145,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -145,10 +163,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -160,10 +181,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -175,10 +199,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelErrorEvent) error {
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
return me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
err := me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
syncModelErrorEventWithRecordErrorEvent(me, re)
defer syncRecordErrorEventWithModelErrorEvent(re, me)
return me.Next()
})
syncModelErrorEventWithRecordErrorEvent(me, re)
return err
}
return me.Next()
@@ -190,10 +217,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -205,10 +235,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -220,10 +253,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelEvent) error {
if re, ok := newRecordEventFromModelEvent(me); ok {
return me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
err := me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
syncModelEventWithRecordEvent(me, re)
defer syncRecordEventWithModelEvent(re, me)
return me.Next()
})
syncModelEventWithRecordEvent(me, re)
return err
}
return me.Next()
@@ -235,10 +271,13 @@ func (app *BaseApp) registerRecordHooks() {
Id: systemHookIdRecord,
Func: func(me *ModelErrorEvent) error {
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
return me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
err := me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
syncModelErrorEventWithRecordErrorEvent(me, re)
defer syncRecordErrorEventWithModelErrorEvent(re, me)
return me.Next()
})
syncModelErrorEventWithRecordErrorEvent(me, re)
return err
}
return me.Next()
@@ -498,18 +537,22 @@ func newRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringM
func NewRecord(collection *Collection) *Record {
record := &Record{
collection: collection,
data: store.New[any](nil),
customVisibility: store.New[bool](nil),
data: store.New[string, any](nil),
customVisibility: store.New[string, bool](nil),
originalData: make(map[string]any, len(collection.Fields)),
}
// initialize default field values
var fieldName string
for _, field := range collection.Fields {
if field.GetName() == FieldNameId {
fieldName = field.GetName()
if fieldName == FieldNameId {
continue
}
value, _ := field.PrepareValue(record, nil)
record.originalData[field.GetName()] = value
record.originalData[fieldName] = value
}
return record
@@ -589,8 +632,12 @@ func (m *Record) Original() *Record {
func (m *Record) Fresh() *Record {
newRecord := m.Original()
// note: this will also load the Id field though m.Get
newRecord.Load(m.FieldsData())
// note: this will also load the Id field through m.GetRaw
var fieldName string
for _, field := range m.collection.Fields {
fieldName = field.GetName()
newRecord.SetRaw(fieldName, m.GetRaw(fieldName))
}
return newRecord
}
@@ -609,7 +656,10 @@ func (m *Record) Clone() *Record {
newRecord.ignoreUnchangedFields = m.ignoreUnchangedFields
newRecord.customVisibility.Reset(m.customVisibility.GetAll())
newRecord.Load(m.data.GetAll())
data := m.data.GetAll()
for k, v := range data {
newRecord.SetRaw(k, v)
}
if m.expand != nil {
newRecord.SetExpand(m.expand.GetAll())
@@ -631,7 +681,7 @@ func (m *Record) Expand() map[string]any {
// SetExpand replaces the current Record's expand with the provided expand arg data (shallow copied).
func (m *Record) SetExpand(expand map[string]any) {
if m.expand == nil {
m.expand = store.New[any](nil)
m.expand = store.New[string, any](nil)
}
m.expand.Reset(expand)
@@ -722,8 +772,10 @@ func (m *Record) MergeExpand(expand map[string]any) {
func (m *Record) FieldsData() map[string]any {
result := make(map[string]any, len(m.collection.Fields))
var fieldName string
for _, field := range m.collection.Fields {
result[field.GetName()] = m.Get(field.GetName())
fieldName = field.GetName()
result[fieldName] = m.Get(fieldName)
}
return result
@@ -781,6 +833,10 @@ func (m *Record) IgnoreEmailVisibility(state bool) *Record {
//
// This could be used if you want to save only the record fields that you've changed
// without overwrite other untouched fields in case of concurrent update.
//
// Note that the fields change comparison is based on the current fields against m.Original()
// (aka. if you have performed save on the same Record instance multiple times you may have to refetch it,
// so that m.Original() could reflect the last saved change).
func (m *Record) IgnoreUnchangedFields(state bool) *Record {
m.ignoreUnchangedFields = state
return m
@@ -1057,15 +1113,18 @@ func (m *Record) dbExport() (map[string]any, error) {
result := make(map[string]any, len(fields))
var fieldName string
for _, field := range fields {
fieldName = field.GetName()
if f, ok := field.(DriverValuer); ok {
v, err := f.DriverValue(m)
if err != nil {
return nil, err
}
result[field.GetName()] = v
result[fieldName] = v
} else {
result[field.GetName()] = m.GetRaw(field.GetName())
result[fieldName] = m.GetRaw(fieldName)
}
}
@@ -1191,8 +1250,11 @@ func (record *Record) PublicExport() map[string]any {
customVisibility := record.customVisibility.GetAll()
// export schema fields
var fieldName string
for _, f := range record.collection.Fields {
isVisible, hasCustomVisibility = customVisibility[f.GetName()]
fieldName = f.GetName()
isVisible, hasCustomVisibility = customVisibility[fieldName]
if !hasCustomVisibility {
isVisible = !f.GetHidden()
}
@@ -1201,7 +1263,7 @@ func (record *Record) PublicExport() map[string]any {
continue
}
export[f.GetName()] = record.Get(f.GetName())
export[fieldName] = record.Get(fieldName)
}
// export custom fields
@@ -1351,12 +1413,18 @@ func onRecordValidate(e *RecordEvent) error {
func onRecordSaveExecute(e *RecordEvent) error {
if e.Record.Collection().IsAuth() {
// ensure that the token key is different on password change
old := e.Record.Original()
if !e.Record.IsNew() &&
old.TokenKey() == e.Record.TokenKey() &&
old.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) {
e.Record.RefreshTokenKey()
// ensure that the token key is regenerated on password change or email change
if !e.Record.IsNew() {
lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
if err != nil {
return err
}
if lastSavedRecord.TokenKey() == e.Record.TokenKey() &&
(lastSavedRecord.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) ||
lastSavedRecord.Email() != e.Record.Email()) {
e.Record.RefreshTokenKey()
}
}
// cross-check that the auth record id is unique across all auth collections.
@@ -1394,7 +1462,7 @@ func onRecordDeleteExecute(e *RecordEvent) error {
//
// note: the select is outside of the transaction to minimize
// SQLITE_BUSY errors when mixing read&write in a single transaction
refs, err := e.App.FindCollectionReferences(e.Record.Collection())
refs, err := e.App.FindCachedCollectionReferences(e.Record.Collection())
if err != nil {
return err
}
@@ -1433,12 +1501,13 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
for _, refCollection := range sortedRefKeys {
fields, ok := refs[refCollection]
if refCollection.IsView() || !ok {
if !ok || refCollection.IsView() {
continue // skip missing or view collections
}
recordTableName := inflector.Columnify(refCollection.Name)
for _, field := range fields {
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName())
query := app.RecordQuery(refCollection)
+21
View File
@@ -1,5 +1,7 @@
package core
import "github.com/pocketbase/pocketbase/tools/security"
// Email returns the "email" record field value (usually available with Auth collections).
func (m *Record) Email() string {
return m.GetString(FieldNameEmail)
@@ -51,6 +53,25 @@ func (m *Record) SetPassword(password string) {
m.Set(FieldNamePassword, password)
}
// SetRandomPassword sets the "password" auth record field to a random autogenerated value.
//
// The autogenerated password is ~30 characters and it is set directly as hash,
// aka. the field plain password value validators (length, pattern, etc.) are ignored
// (this is usually used as part of the auto created OTP or OAuth2 user flows).
func (m *Record) SetRandomPassword() string {
pass := security.RandomString(30)
m.Set(FieldNamePassword, pass)
m.RefreshTokenKey() // manually refresh the token key because the plain password is resetted
// unset the plain value to skip the field validators
if raw, ok := m.GetRaw(FieldNamePassword).(*PasswordFieldValue); ok {
raw.Plain = ""
}
return pass
}
// ValidatePassword validates a plain password against the "password" record field.
//
// Returns false if the password is incorrect.
+41
View File
@@ -1,9 +1,11 @@
package core_test
import (
"context"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -117,3 +119,42 @@ func TestRecordPassword(t *testing.T) {
})
}
}
func TestRecordSetRandomPassword(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
oldTokenKey := "old_tokenKey"
record := core.NewRecord(core.NewAuthCollection("test"))
record.SetTokenKey(oldTokenKey)
pass := record.SetRandomPassword()
if pass == "" {
t.Fatal("Expected non-empty generated random password")
}
if !record.ValidatePassword(pass) {
t.Fatal("Expected the generated random password to be valid")
}
if record.TokenKey() == oldTokenKey {
t.Fatal("Expected token key to change")
}
f, ok := record.Collection().Fields.GetByName(core.FieldNamePassword).(*core.PasswordField)
if !ok {
t.Fatal("Expected *core.PasswordField")
}
// ensure that the field validators will be ignored
f.Min = 1
f.Max = 2
f.Pattern = `\d+`
if err := f.ValidateValue(context.Background(), app, record); err != nil {
t.Fatalf("Expected password field plain value validators to be ignored, got %v", err)
}
}
+39 -1
View File
@@ -1,6 +1,8 @@
package core
import (
"database/sql"
"errors"
"fmt"
"github.com/pocketbase/pocketbase/tools/hook"
@@ -9,6 +11,10 @@ import (
const CollectionNameSuperusers = "_superusers"
// DefaultInstallerEmail is the default superuser email address
// for the initial autogenerated superuser account.
const DefaultInstallerEmail = "__pbinstaller@example.com"
func (app *BaseApp) registerSuperuserHooks() {
app.OnRecordDelete(CollectionNameSuperusers).Bind(&hook.Handler[*RecordEvent]{
Id: "pbSuperusersRecordDelete",
@@ -39,13 +45,45 @@ func (app *BaseApp) registerSuperuserHooks() {
Id: "pbSuperusersRecordSaveExec",
Func: func(e *RecordEvent) error {
e.Record.SetVerified(true) // always mark superusers as verified
return e.Next()
if err := e.Next(); err != nil {
return err
}
// ensure that the installer superuser is deleted
if e.Type == ModelEventTypeCreate && e.Record.Email() != DefaultInstallerEmail {
record, err := app.FindAuthRecordByEmail(CollectionNameSuperusers, DefaultInstallerEmail)
if errors.Is(err, sql.ErrNoRows) {
// already deleted
} else if err != nil {
e.App.Logger().Warn("Failed to fetch installer superuser", "error", err)
} else {
err = e.App.Delete(record)
if err != nil {
e.App.Logger().Warn("Failed to delete installer superuser", "error", err)
}
}
}
return nil
},
Priority: -99,
}
app.OnRecordCreateExecute(CollectionNameSuperusers).Bind(recordSaveHandler)
app.OnRecordUpdateExecute(CollectionNameSuperusers).Bind(recordSaveHandler)
// prevent sending password reset emails to the installer address
app.OnMailerRecordPasswordResetSend(CollectionNameSuperusers).Bind(&hook.Handler[*MailerRecordEvent]{
Id: "pbSuperusersInstallerPasswordReset",
Func: func(e *MailerRecordEvent) error {
if e.Record.Email() == DefaultInstallerEmail {
return errors.New("cannot reset the password for the installer superuser")
}
return e.Next()
},
})
collectionSaveHandler := &hook.Handler[*CollectionEvent]{
Id: "pbSuperusersCollectionSaveExec",
Func: func(e *CollectionEvent) error {
+70 -29
View File
@@ -244,10 +244,13 @@ func TestRecordFresh(t *testing.T) {
extraFieldsCheck := []string{`"email":`, `"custom":`}
autodateTest := types.NowDateTime()
// change the fields
record.Id = "changed"
record.Set("name", "name_new")
record.Set("custom", "test_custom")
record.SetRaw("created", autodateTest)
record.SetExpand(map[string]any{"test": 123})
record.IgnoreEmailVisibility(true)
record.IgnoreUnchangedFields(true)
@@ -270,6 +273,9 @@ func TestRecordFresh(t *testing.T) {
if v := record.GetString("name"); v != "name_new" {
t.Fatalf("Expected name to be %q, got %q", "name_new", v)
}
if v := record.GetDateTime("created").String(); v != autodateTest.String() {
t.Fatalf("Expected created to be %q, got %q", autodateTest.String(), v)
}
if v := record.GetString("custom"); v != "test_custom" {
t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
}
@@ -287,6 +293,12 @@ func TestRecordFresh(t *testing.T) {
if v := record.Fresh().GetString("name"); v != record.GetString("name") {
t.Fatalf("Expected the fresh name to be %q, got %q", record.GetString("name"), v)
}
if v := record.Fresh().GetDateTime("created").String(); v != autodateTest.String() {
t.Fatalf("Expected the fresh created to be %q, got %q", autodateTest.String(), v)
}
if v := record.Fresh().GetDateTime("updated").String(); v != record.GetDateTime("updated").String() {
t.Fatalf("Expected the fresh updated to be %q, got %q", record.GetDateTime("updated").String(), v)
}
if v := record.Fresh().GetString("custom"); v != "" {
t.Fatalf("Expected the fresh custom to be %q, got %q", "", v)
}
@@ -321,10 +333,13 @@ func TestRecordClone(t *testing.T) {
extraFieldsCheck := []string{`"email":`, `"custom":`}
autodateTest := types.NowDateTime()
// change the fields
record.Id = "changed"
record.Set("name", "name_new")
record.Set("custom", "test_custom")
record.SetRaw("created", autodateTest)
record.SetExpand(map[string]any{"test": 123})
record.IgnoreEmailVisibility(true)
record.WithCustomData(true)
@@ -346,6 +361,9 @@ func TestRecordClone(t *testing.T) {
if v := record.GetString("name"); v != "name_new" {
t.Fatalf("Expected name to be %q, got %q", "name_new", v)
}
if v := record.GetDateTime("created").String(); v != autodateTest.String() {
t.Fatalf("Expected created to be %q, got %q", autodateTest.String(), v)
}
if v := record.GetString("custom"); v != "test_custom" {
t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
}
@@ -363,6 +381,12 @@ func TestRecordClone(t *testing.T) {
if v := record.Clone().GetString("name"); v != record.GetString("name") {
t.Fatalf("Expected the clone name to be %q, got %q", record.GetString("name"), v)
}
if v := record.Clone().GetDateTime("created").String(); v != autodateTest.String() {
t.Fatalf("Expected the clone created to be %q, got %q", autodateTest.String(), v)
}
if v := record.Clone().GetDateTime("updated").String(); v != record.GetDateTime("updated").String() {
t.Fatalf("Expected the clone updated to be %q, got %q", record.GetDateTime("updated").String(), v)
}
if v := record.Clone().GetString("custom"); v != "test_custom" {
t.Fatalf("Expected the clone custom to be %q, got %q", "test_custom", v)
}
@@ -1564,13 +1588,15 @@ func TestRecordValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewBaseCollection("test")
// dummy collection to ensure that the specified field validators are triggered
collection := core.NewBaseCollection("validate_test")
collection.Fields.Add(
// dummy fields to ensure that its validators are triggered
&core.TextField{Name: "f1", Min: 3},
&core.NumberField{Name: "f2", Required: true},
)
if err := app.Save(collection); err != nil {
t.Fatal(err)
}
record := core.NewRecord(collection)
record.Id = "!invalid"
@@ -1585,7 +1611,7 @@ func TestRecordValidate(t *testing.T) {
})
t.Run("satisfying the fields validations", func(t *testing.T) {
record.Id = strings.Repeat("a", 15)
record.Id = strings.Repeat("b", 15)
record.Set("f1", "abc")
record.Set("f2", 1)
tests.TestValidationErrors(t, app.Validate(record), nil)
@@ -1796,44 +1822,59 @@ func TestRecordSaveIdUpdateNoValidation(t *testing.T) {
}
}
func TestRecordSaveWithChangedPassword(t *testing.T) {
func TestRecordSaveWithAutoTokenKeyRefresh(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
record, err := app.FindAuthRecordByEmail("nologin", "test@example.com")
if err != nil {
t.Fatal(err)
scenarios := []struct {
name string
payload map[string]any
expectedChange bool
}{
{
"no email or password change",
map[string]any{"name": "example"},
false,
},
{
"password change",
map[string]any{"password": "1234567890"},
true,
},
{
"email change",
map[string]any{"email": "test_update@example.com"},
true,
},
}
originalTokenKey := record.TokenKey()
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
record, err := app.FindFirstRecordByFilter("nologin", "1=1")
if err != nil {
t.Fatal(err)
}
t.Run("no password change shouldn't change the tokenKey", func(t *testing.T) {
record.Set("name", "example")
originalTokenKey := record.TokenKey()
if err := app.Save(record); err != nil {
t.Fatal(err)
}
record.Load(s.payload)
tokenKey := record.TokenKey()
if tokenKey == "" || originalTokenKey != tokenKey {
t.Fatalf("Expected tokenKey to not change, got %q VS %q", originalTokenKey, tokenKey)
}
})
err = app.Save(record)
if err != nil {
t.Fatal(err)
}
t.Run("password change should change the tokenKey", func(t *testing.T) {
record.Set("password", "1234567890")
newTokenKey := record.TokenKey()
if err := app.Save(record); err != nil {
t.Fatal(err)
}
hasChange := originalTokenKey != newTokenKey
tokenKey := record.TokenKey()
if tokenKey == "" || originalTokenKey == tokenKey {
t.Fatalf("Expected tokenKey to change, got %q VS %q", originalTokenKey, tokenKey)
}
})
if hasChange != s.expectedChange {
t.Fatalf("Expected hasChange %v, got %v", s.expectedChange, hasChange)
}
})
}
}
func TestRecordDelete(t *testing.T) {
+10 -10
View File
@@ -102,11 +102,6 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
return nil
default: // expects []RecordProxy slice
records, err := resolveRecordAllHook(collection, op)
if err != nil {
return err
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return errors.New("must be a pointer")
@@ -118,11 +113,6 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
return errors.New("must be a slice of RecordSetters")
}
// create an empty slice
if rv.IsNil() {
rv.Set(reflect.MakeSlice(rv.Type(), 0, len(records)))
}
et := rv.Type().Elem()
var isSliceOfPointers bool
@@ -135,6 +125,16 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
return op(sliceA)
}
records, err := resolveRecordAllHook(collection, op)
if err != nil {
return err
}
// create an empty slice
if rv.IsNil() {
rv.Set(reflect.MakeSlice(rv.Type(), 0, len(records)))
}
for _, record := range records {
ev := reflect.New(et)
+33 -3
View File
@@ -66,7 +66,7 @@ func TestRecordQueryOne(t *testing.T) {
name string
collection string
recordId string
model core.Model
model any
}{
{
"record model",
@@ -82,6 +82,14 @@ func TestRecordQueryOne(t *testing.T) {
core.BaseRecordProxy
}{},
},
{
"custom struct",
"demo1",
"84nmscqy84lsi1t",
&struct {
Id string `db:"id" json:"id"`
}{},
},
}
for _, s := range scenarios {
@@ -98,8 +106,14 @@ func TestRecordQueryOne(t *testing.T) {
t.Fatal(err)
}
if s.model.PK() != s.recordId {
t.Fatalf("Expected record with id %q, got %q", s.recordId, s.model.PK())
raw, err := json.Marshal(s.model)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
if !strings.Contains(rawStr, fmt.Sprintf(`"id":%q`, s.recordId)) {
t.Fatalf("Missing id %q in\n%s", s.recordId, rawStr)
}
})
}
@@ -111,6 +125,10 @@ func TestRecordQueryAll(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
type customStructs struct {
Id string `db:"id" json:"id"`
}
type mockRecordProxy struct {
core.BaseRecordProxy
}
@@ -145,6 +163,18 @@ func TestRecordQueryAll(t *testing.T) {
[]any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
&[]mockRecordProxy{},
},
{
"slice of custom structs",
"demo1",
[]any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
&[]customStructs{},
},
{
"slice of pointer custom structs",
"demo1",
[]any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
&[]customStructs{},
},
}
for _, s := range scenarios {
+44 -13
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
@@ -123,10 +124,10 @@ type settings struct {
Backups BackupsConfig `form:"backups" json:"backups"`
S3 S3Config `form:"s3" json:"s3"`
Meta MetaConfig `form:"meta" json:"meta"`
Logs LogsConfig `form:"logs" json:"logs"`
Batch BatchConfig `form:"batch" json:"batch"`
RateLimits RateLimitsConfig `form:"rateLimits" json:"rateLimits"`
TrustedProxy TrustedProxyConfig `form:"trustedProxy" json:"trustedProxy"`
Batch BatchConfig `form:"batch" json:"batch"`
Logs LogsConfig `form:"logs" json:"logs"`
}
// Settings defines the PocketBase app settings.
@@ -529,7 +530,7 @@ type TrustedProxyConfig struct {
// UseLeftmostIP specifies to use the left-mostish IP from the trusted headers.
//
// Note that this could be insecure when used with X-Forward-For header
// Note that this could be insecure when used with X-Forwarded-For header
// because some proxies like AWS ELB allow users to prepend their own header value
// before appending the trusted ones.
UseLeftmostIP bool `form:"useLeftmostIP" json:"useLeftmostIP"`
@@ -560,13 +561,17 @@ type RateLimitsConfig struct {
}
// FindRateLimitRule returns the first matching rule based on the provided labels.
func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string) (RateLimitRule, bool) {
//
// Optionally you can further specify a list of valid RateLimitRule.Audience values to further filter the matching rule
// (aka. the rule Audience will have to exist in one of the specified options).
func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string, optOnlyAudience ...string) (RateLimitRule, bool) {
var prefixRules []int
for i, label := range searchLabels {
// check for direct match
for j := range c.Rules {
if label == c.Rules[j].Label {
if label == c.Rules[j].Label &&
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[j].Audience)) {
return c.Rules[j], true
}
@@ -578,7 +583,8 @@ func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string) (RateLimitRu
// check for prefix match
if len(prefixRules) > 0 {
for j := range prefixRules {
if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) {
if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) &&
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[prefixRules[j]].Audience)) {
return c.Rules[prefixRules[j]], true
}
}
@@ -617,19 +623,28 @@ func checkUniqueRuleLabel(value any) error {
return validators.ErrUnsupportedValueType
}
labels := make(map[string]struct{}, len(rules))
existing := make([]string, 0, len(rules))
for i, rule := range rules {
_, ok := labels[rule.Label]
if ok {
fullKey := rule.Label + "@@" + rule.Audience
var conflicts bool
for _, key := range existing {
if strings.HasPrefix(key, fullKey) || strings.HasPrefix(fullKey, key) {
conflicts = true
break
}
}
if conflicts {
return validation.Errors{
strconv.Itoa(i): validation.Errors{
"label": validation.NewError("validation_duplicated_rate_limit_tag", "Rate limit tag with label "+rule.Label+" already exists.").
"label": validation.NewError("validation_conflicting_rate_limit_rule", "Rate limit rule configuration with label {{.label}} already exists or conflicts with another rule.").
SetParams(map[string]any{"label": rule.Label}),
},
}
} else {
labels[rule.Label] = struct{}{}
existing = append(existing, fullKey)
}
}
@@ -638,6 +653,13 @@ func checkUniqueRuleLabel(value any) error {
var rateLimitRuleLabelRegex = regexp.MustCompile(`^(\w+\ \/[\w\/-]*|\/[\w\/-]*|^\w+\:\w+|\*\:\w+|\w+)$`)
// The allowed RateLimitRule.Audience values
const (
RateLimitRuleAudienceAll = ""
RateLimitRuleAudienceGuest = "@guest"
RateLimitRuleAudienceAuth = "@auth"
)
type RateLimitRule struct {
// Label is the identifier of the current rule.
//
@@ -652,12 +674,18 @@ type RateLimitRule struct {
// - POST /api/collections/
Label string `form:"label" json:"label"`
// MaxRequests is the max allowed number of requests per Duration.
MaxRequests int `form:"maxRequests" json:"maxRequests"`
// Audience specifies the auth group the rule should apply for:
// - "" - both guests and authenticated users (default)
// - "guest" - only for guests
// - "auth" - only for authenticated users
Audience string `form:"audience" json:"audience"`
// Duration specifies the interval (in seconds) per which to reset
// the counted/accumulated rate limiter tokens.
Duration int64 `form:"duration" json:"duration"`
// MaxRequests is the max allowed number of requests per Duration.
MaxRequests int `form:"maxRequests" json:"maxRequests"`
}
// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
@@ -666,6 +694,9 @@ func (c RateLimitRule) Validate() error {
validation.Field(&c.Label, validation.Required, validation.Match(rateLimitRuleLabelRegex)),
validation.Field(&c.MaxRequests, validation.Required, validation.Min(1)),
validation.Field(&c.Duration, validation.Required, validation.Min(1)),
validation.Field(&c.Audience,
validation.In(RateLimitRuleAudienceAll, RateLimitRuleAudienceGuest, RateLimitRuleAudienceAuth),
),
)
}
+139 -18
View File
@@ -106,7 +106,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
}
rawStr := string(raw)
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false}}`
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -539,6 +539,86 @@ func TestRateLimitsConfigValidate(t *testing.T) {
},
[]string{},
},
{
"duplicated rules with the same audience",
core.RateLimitsConfig{
Enabled: true,
Rules: []core.RateLimitRule{
{
Label: "/a",
Duration: 1,
MaxRequests: 2,
},
{
Label: "/a",
Duration: 2,
MaxRequests: 3,
},
},
},
[]string{"rules"},
},
{
"duplicated rule with conflicting audience (A)",
core.RateLimitsConfig{
Enabled: true,
Rules: []core.RateLimitRule{
{
Label: "/a",
Duration: 1,
MaxRequests: 2,
},
{
Label: "/a",
Duration: 1,
MaxRequests: 2,
Audience: core.RateLimitRuleAudienceGuest,
},
},
},
[]string{"rules"},
},
{
"duplicated rule with conflicting audience (B)",
core.RateLimitsConfig{
Enabled: true,
Rules: []core.RateLimitRule{
{
Label: "/a",
Duration: 1,
MaxRequests: 2,
Audience: core.RateLimitRuleAudienceAuth,
},
{
Label: "/a",
Duration: 1,
MaxRequests: 2,
},
},
},
[]string{"rules"},
},
{
"duplicated rule with non-conflicting audience",
core.RateLimitsConfig{
Enabled: true,
Rules: []core.RateLimitRule{
{
Label: "/a",
Duration: 1,
MaxRequests: 2,
Audience: core.RateLimitRuleAudienceAuth,
},
{
Label: "/a",
Duration: 1,
MaxRequests: 2,
Audience: core.RateLimitRuleAudienceGuest,
},
},
},
[]string{},
},
}
for _, s := range scenarios {
@@ -554,33 +634,43 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
limits := core.RateLimitsConfig{
Rules: []core.RateLimitRule{
{Label: "abc"},
{Label: "POST /test/a/"},
{Label: "/test/a/"},
{Label: "def", Audience: core.RateLimitRuleAudienceGuest},
{Label: "/test/a", Audience: core.RateLimitRuleAudienceGuest},
{Label: "POST /test/a"},
{Label: "/test/a"},
{Label: "/test/a/", Audience: core.RateLimitRuleAudienceAuth},
{Label: "POST /test/a/"},
},
}
scenarios := []struct {
labels []string
audience []string
expected string
}{
{[]string{}, ""},
{[]string{"missing"}, ""},
{[]string{"abc"}, "abc"},
{[]string{"/test"}, ""},
{[]string{"/test/a"}, "/test/a"},
{[]string{"GET /test/a"}, ""},
{[]string{"POST /test/a"}, "POST /test/a"},
{[]string{"/test/a/b/c"}, "/test/a/"},
{[]string{"GET /test/a/b/c"}, ""},
{[]string{"POST /test/a/b/c"}, "POST /test/a/"},
{[]string{"/test/a", "abc"}, "/test/a"}, // priority checks
{[]string{}, []string{}, ""},
{[]string{"missing"}, []string{}, ""},
{[]string{"abc"}, []string{}, "abc"},
{[]string{"abc"}, []string{core.RateLimitRuleAudienceGuest}, ""},
{[]string{"abc"}, []string{core.RateLimitRuleAudienceAuth}, ""},
{[]string{"def"}, []string{core.RateLimitRuleAudienceGuest}, "def"},
{[]string{"def"}, []string{core.RateLimitRuleAudienceAuth}, ""},
{[]string{"/test"}, []string{}, ""},
{[]string{"/test/a"}, []string{}, "/test/a"},
{[]string{"/test/a"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
{[]string{"/test/a"}, []string{core.RateLimitRuleAudienceGuest}, "/test/a"},
{[]string{"GET /test/a"}, []string{}, ""},
{[]string{"POST /test/a"}, []string{}, "POST /test/a"},
{[]string{"/test/a/b/c"}, []string{}, "/test/a/"},
{[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
{[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceGuest}, ""},
{[]string{"GET /test/a/b/c"}, []string{}, ""},
{[]string{"POST /test/a/b/c"}, []string{}, "POST /test/a/"},
{[]string{"/test/a", "abc"}, []string{}, "/test/a"}, // priority checks
}
for _, s := range scenarios {
t.Run(strings.Join(s.labels, ""), func(t *testing.T) {
rule, ok := limits.FindRateLimitRule(s.labels)
t.Run(strings.Join(s.labels, "_")+":"+strings.Join(s.audience, "_"), func(t *testing.T) {
rule, ok := limits.FindRateLimitRule(s.labels, s.audience...)
hasLabel := rule.Label != ""
if hasLabel != ok {
@@ -611,8 +701,9 @@ func TestRateLimitRuleValidate(t *testing.T) {
Label: "@abc",
Duration: -1,
MaxRequests: -1,
Audience: "invalid",
},
[]string{"label", "duration", "maxRequests"},
[]string{"label", "duration", "maxRequests", "audience"},
},
{
"valid data (name)",
@@ -659,6 +750,36 @@ func TestRateLimitRuleValidate(t *testing.T) {
},
[]string{},
},
{
"invalid audience",
core.RateLimitRule{
Label: "/a/b/",
Duration: 1,
MaxRequests: 1,
Audience: "invalid",
},
[]string{"audience"},
},
{
"valid audience - " + core.RateLimitRuleAudienceGuest,
core.RateLimitRule{
Label: "POST /a/b/",
Duration: 1,
MaxRequests: 1,
Audience: core.RateLimitRuleAudienceGuest,
},
[]string{},
},
{
"valid audience - " + core.RateLimitRuleAudienceAuth,
core.RateLimitRule{
Label: "POST /a/b/",
Duration: 1,
MaxRequests: 1,
Audience: core.RateLimitRuleAudienceAuth,
},
[]string{},
},
}
for _, s := range scenarios {
+1 -1
View File
@@ -29,7 +29,7 @@ func UploadedFileSize(maxBytes int64) validation.RuleFunc {
if v.Size > 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),
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
).SetParams(map[string]any{
"file": v.OriginalName,
"maxSize": maxBytes,
+1
View File
@@ -247,6 +247,7 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
System: true,
Required: true,
PrimaryKey: true,
Pattern: `^[a-z0-9]+$`,
},
}
continue
+17 -17
View File
@@ -27,9 +27,9 @@ type RecordUpsert struct {
accessLevel int
// extra password fields
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
OldPassword string `form:"oldPassword" json:"oldPassword"`
password string
passwordConfirm string
oldPassword string
}
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
@@ -93,13 +93,13 @@ func (form *RecordUpsert) Load(data map[string]any) {
// load the special auth form fields
if isAuth {
if v, ok := data["password"]; ok {
form.Password = cast.ToString(v)
form.password = cast.ToString(v)
}
if v, ok := data["passwordConfirm"]; ok {
form.PasswordConfirm = cast.ToString(v)
form.passwordConfirm = cast.ToString(v)
}
if v, ok := data["oldPassword"]; ok {
form.OldPassword = cast.ToString(v)
form.oldPassword = cast.ToString(v)
}
excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
@@ -137,9 +137,9 @@ func (form *RecordUpsert) validateFormFields() error {
validateData := map[string]any{
"email": form.record.Email(),
"verified": form.record.Verified(),
"password": form.Password,
"passwordConfirm": form.PasswordConfirm,
"oldPassword": form.OldPassword,
"password": form.password,
"passwordConfirm": form.passwordConfirm,
"oldPassword": form.oldPassword,
}
return validation.Validate(validateData,
@@ -165,17 +165,17 @@ func (form *RecordUpsert) validateFormFields() error {
validation.Key(
"password",
validation.When(
(isNew || form.PasswordConfirm != "" || form.OldPassword != ""),
(isNew || form.passwordConfirm != "" || form.oldPassword != ""),
validation.Required,
),
),
validation.Key(
"passwordConfirm",
validation.When(
(isNew || form.Password != "" || form.OldPassword != ""),
(isNew || form.password != "" || form.oldPassword != ""),
validation.Required,
),
validation.By(validators.Equal(form.Password)),
validation.By(validators.Equal(form.password)),
),
validation.Key(
"oldPassword",
@@ -183,7 +183,7 @@ func (form *RecordUpsert) validateFormFields() error {
// - form.HasManageAccess() is not satisfied
// - changing the existing password
validation.When(
!isNew && !form.HasManageAccess() && (form.Password != "" || form.PasswordConfirm != ""),
!isNew && !form.HasManageAccess() && (form.password != "" || form.passwordConfirm != ""),
validation.Required,
validation.By(form.checkOldPassword),
),
@@ -193,9 +193,9 @@ func (form *RecordUpsert) validateFormFields() error {
}
func (form *RecordUpsert) checkOldPassword(value any) error {
v, _ := value.(string)
if v == "" || form.record.IsNew() {
return nil // nothing to check
v, ok := value.(string)
if !ok {
return validators.ErrUnsupportedValueType
}
if !form.record.Original().ValidatePassword(v) {
@@ -205,7 +205,7 @@ func (form *RecordUpsert) checkOldPassword(value any) error {
return nil
}
// @todo consider removing and executing the Create API rule without dummy insert.
// Deprecated: It was previously used as part of the record create action but it is not needed anymore and will be removed in the future.
//
// DrySubmit performs a temp form submit within a transaction and reverts it at the end.
// For actual record persistence, check the [RecordUpsert.Submit()] method.
+16 -14
View File
@@ -64,15 +64,15 @@ func TestRecordUpsertLoad(t *testing.T) {
`"text":"test_text"`,
`"number":456`,
`"select_many":["optionB","optionC"]`,
`"password":""`,
`"oldPassword":""`,
`"passwordConfirm":""`,
`"created":""`,
`"updated":""`,
`"json":null`,
},
notExpected: []string{
`"custom"`,
`"password"`,
`"oldPassword"`,
`"passwordConfirm"`,
`"select_many-"`,
`"select_many+"`,
},
@@ -89,9 +89,11 @@ func TestRecordUpsertLoad(t *testing.T) {
record: core.NewRecord(usersCol),
expected: []string{
`"email":"test@example.com"`,
`"oldPassword":"123"`,
`"password":"456"`,
`"passwordConfirm":"789"`,
},
notExpected: []string{
`"oldPassword"`,
`"passwordConfirm"`,
},
},
{
@@ -110,8 +112,10 @@ func TestRecordUpsertLoad(t *testing.T) {
`"email":"test@example.com"`,
`"tokenKey":""`,
`"password":"456"`,
`"oldPassword":"123"`,
`"passwordConfirm":"789"`,
},
notExpected: []string{
`"oldPassword"`,
`"passwordConfirm"`,
},
},
{
@@ -130,8 +134,10 @@ func TestRecordUpsertLoad(t *testing.T) {
`"email":"test@example.com"`,
`"tokenKey":"abc"`,
`"password":"456"`,
`"oldPassword":"123"`,
`"passwordConfirm":"789"`,
},
notExpected: []string{
`"oldPassword"`,
`"passwordConfirm"`,
},
},
{
@@ -168,11 +174,7 @@ func TestRecordUpsertLoad(t *testing.T) {
form.Load(s.data)
loaded := map[string]any{
"oldPassword": form.OldPassword,
"password": form.Password,
"passwordConfirm": form.PasswordConfirm,
}
loaded := map[string]any{}
maps.Copy(loaded, s.record.FieldsData())
maps.Copy(loaded, s.record.CustomData())
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff. Show more