Compare commits

...
180 Commits
Author SHA1 Message Date
Gani Georgiev 16539335be updated golang-jwt 2025-03-22 06:08:31 +02:00
Gani Georgiev e6573d2549 [#6600] removed filesystem io.EOF error wrapping 2025-03-16 10:43:21 +02:00
Gani Georgiev 8aa3545efe updated changelog 2025-03-15 14:36:43 +02:00
Gani Georgiev 7f7d451b31 updated go deps 2025-03-15 13:30:41 +02:00
Gani Georgiev 44007a7c8c reorganized sqlLogReplacements 2025-03-15 13:28:39 +02:00
Gani Georgiev 613af90fac bumped app version 2025-03-15 12:41:44 +02:00
Gani Georgiev 64e2931379 fixed sql nested json value dev print log 2025-03-15 12:29:51 +02:00
Gani Georgiev 6e9ecb2b7a updated modernc.org/sqlite dependency 2025-03-14 23:10:28 +02:00
Gani Georgiev 04a3dc7bb7 fixed typos and pass the correct custom abortCtx 2025-03-14 23:09:24 +02:00
Gani Georgiev 24c4b63960 [#6590] apply nullifyMisingField for request.auth.* and request.body.* back relations when the relation field is pointing to a different collection 2025-03-14 23:00:40 +02:00
Gani Georgiev 4ced91f95d updated ui/dist 2025-03-14 09:11:41 +02:00
Gani Georgiev 33613b27b0 [#6481] allowed calling cronAdd and cronRemove from inside other JSVM handlers 2025-03-11 15:00:56 +02:00
Gani Georgiev 4e148f7224 added log warning for async marked JSVM handlers and resolve when possible the returned Promise as fallback 2025-03-10 19:00:46 +02:00
Gani Georgiev 4a7a639df1 added s3blob WriterOptions custom headers test 2025-03-09 20:56:33 +02:00
Gani Georgiev ed36add853 added basic s3blob tests 2025-03-08 22:32:00 +02:00
Gani Georgiev f799083c4f added godoc comments and license notes for the gocloud.dev vendored code 2025-03-08 01:10:13 +02:00
Gani Georgiev 087eaa7ea4 updated tygoja and the generated jsvm types 2025-03-07 20:52:54 +02:00
Gani Georgiev 803ebb8f40 bumped default server timeouts 2025-03-06 21:45:16 +02:00
Gani Georgiev 58dab5bf70 restored DynamicModel types cache 2025-03-06 21:26:57 +02:00
Gani Georgiev 856cc604a7 synced with master 2025-03-06 12:50:07 +02:00
Gani Georgiev 3912874517 updated changelog 2025-03-05 23:56:05 +02:00
Gani Georgiev 5c58703a59 [#6563] fixed DynamicModel object/array props reflect type caching 2025-03-05 23:55:56 +02:00
Gani Georgiev c054c3b0a5 updated ui/dist and jsvm types 2025-03-05 22:19:36 +02:00
Gani Georgiev 3da16b2d74 updated go deps 2025-03-05 20:17:48 +02:00
Gani Georgiev dd5afd44d2 updated modernc.org/sqlite 2025-03-05 16:54:40 +02:00
Gani Georgiev 501c49012e [poc] replaced aws-sdk-go-v2 and gocloud.dev/blob 2025-03-05 16:31:21 +02:00
Gani Georgiev 48a9b82024 synced with master 2025-02-25 20:12:09 +02:00
Gani Georgiev 4155f50fe1 [#6529] added default leeway for the id_token checks 2025-02-24 11:45:59 +02:00
Gani Georgiev 653f2d8b16 [#6493] fixed request.body.json.* values extraction 2025-02-21 18:00:13 +02:00
Gani Georgiev eb3129d1f3 updated ui/dist 2025-02-21 16:54:51 +02:00
Gani Georgiev 4db497c5e1 added subscription.Message.WriteSSE method 2025-02-21 13:04:23 +02:00
Gani Georgiev 973916bb48 synced with master 2025-02-21 12:51:44 +02:00
Gani Georgiev d607695600 [#6490] restore meta.isNew OAuth2 response field 2025-02-21 10:30:43 +02:00
Gani Georgiev 3f51fb941b updated modernc deps and bumped app version 2025-02-18 18:34:38 +02:00
Gani Georgiev 4d40463d8d specified a default goja script name when executing plain JS strings 2025-02-18 12:18:08 +02:00
Gani Georgiev 543b8f5e7a added fields example to the JSON method 2025-02-17 22:35:15 +02:00
Gani Georgiev 8e39998f63 added clarification tooltips for the create and update API rules 2025-02-15 18:40:42 +02:00
Gani Georgiev e8a554b4ad synced with master 2025-02-15 17:04:25 +02:00
Gani Georgiev 5aa380927a dowgraded aws-sdk-go-v2 2025-02-11 20:13:43 +02:00
Gani Georgiev aaa3d67659 added Store.SetFunc method 2025-02-11 13:24:13 +02:00
Gani Georgiev 5c41938cb9 updated logs code comments 2025-02-11 00:38:22 +02:00
Gani Georgiev f1d44c7847 synced with master 2025-02-11 00:25:30 +02:00
Gani Georgiev c0b7762abd [#6440] added a temporary exception for Backblaze S3 endpoints to exclude the new aws-sdk-go-v2 checksum headers 2025-02-10 23:37:23 +02:00
Gani Georgiev 26f0df36bc [#6402] load the request info context during password/OAuth2/OTP authentication 2025-02-10 16:57:25 +02:00
Gani Georgiev 6a7f3a21fb synced with master 2025-02-10 09:38:15 +02:00
Gani Georgiev 2e26f61cb7 updated changelog 2025-02-10 09:15:07 +02:00
Gani Georgiev 59f98cac99 fixed flaky realtime record resolve test 2025-02-09 23:47:08 +02:00
Gani Georgiev 2a1fdc1613 added realtime api record resolve tests 2025-02-09 23:26:41 +02:00
Gani Georgiev f767af0ded bumped app version 2025-02-09 19:41:05 +02:00
Gani Georgiev 920e893e11 [#6433] fixed realtime delete event for RecordProxy and other custom record models 2025-02-09 19:24:45 +02:00
Gani Georgiev 048e534f0d bumped app version 2025-02-08 08:47:01 +02:00
Gani Georgiev 9da7a8f72b [#6407] added os.Stat jsvm binding 2025-02-05 19:07:34 +02:00
Gani Georgiev 9856c59de0 prioritized user submitted OAuth2 createData.email 2025-02-03 12:57:15 +02:00
Gani Georgiev acd72101c6 fixed batch API Preview sample response 2025-02-01 12:04:15 +02:00
Gani Georgiev fa343c37e6 fixed changelog typos 2025-01-31 15:50:07 +02:00
Gani Georgiev d3aac570b2 updated changelog 2025-01-31 15:02:23 +02:00
Gani Georgiev b066d9c429 updated godoc 2025-01-31 14:28:23 +02:00
Gani Georgiev 5d889d00ad Merge branch 'develop' 2025-01-31 14:21:29 +02:00
Gani Georgiev 49baa69e8d updated jsvm types 2025-01-31 14:20:36 +02:00
Gani Georgiev 923b791675 removed unnecessary strings.ToLower 2025-01-31 14:06:48 +02:00
Gani Georgiev 4b489b511d bumped app version and updated dependencies 2025-01-31 13:29:19 +02:00
Gani Georgiev 9b901fcee8 updated changelog and help texts 2025-01-30 17:35:16 +02:00
Gani GeorgievandAidan Rowe c5bd42a23f [#6338] added Trakt OAuth2 provider
Co-authored-by: Aidan Rowe <aidanrowe@gmail.com>
2025-01-27 11:05:30 +02:00
Gani Georgiev 1bc0b78c83 updated API Preview sample error responses 2025-01-26 21:27:57 +02:00
Gani Georgiev 6f3abe7c2d fixed comment typo and updated npm deps 2025-01-26 17:33:08 +02:00
Gani Georgiev 33340a6977 [#6337] added support for case-insensitive password auth 2025-01-26 12:24:37 +02:00
Gani Georgiev c101798516 added inflector.Camelize helper 2025-01-24 14:49:28 +02:00
Gani Georgiev 65440314ce added inflector.Singularize helper 2025-01-24 14:00:41 +02:00
Gani Georgiev 91d4ca5c06 replaced archived survey dep with osutils.YesNoPrompt helper 2025-01-21 21:05:30 +02:00
Gani Georgiev a4a228b368 replaced exists bool db scans with int for broader drivers compatibility 2025-01-20 14:16:00 +02:00
Gani Georgiev 0ebe9c4faa updated npm deps 2025-01-20 14:15:38 +02:00
Gani Georgiev 7da875be14 [#6313] enforced when_required for the new aws sdk request and response cheksum validations 2025-01-19 22:12:27 +02:00
Gani Georgiev 2124b77a2a fixed multiple comment blocks parsing 2025-01-19 22:08:20 +02:00
Gani Georgiev b0e2f67733 fixed multiple comment blocks parsing 2025-01-19 21:04:24 +02:00
Gani Georgiev baf4857bee synced with master 2025-01-18 14:23:13 +02:00
Gani Georgiev 37ff943f67 bumped GitHub action min Go version 2025-01-18 12:48:28 +02:00
Gani Georgiev c7322eec66 updated old v0.22.x changelog 2025-01-18 12:17:07 +02:00
Gani Georgiev 7964669a20 updated changelog 2025-01-18 12:13:55 +02:00
Gani Georgiev b8ea953059 [#6309] fixed fields extraction for view query with multi-level comments 2025-01-18 12:11:36 +02:00
Gani Georgiev f935e2139e bumped modernc sqlite driver version 2025-01-17 16:01:49 +02:00
Gani Georgiev 25dd858c18 execute the delete realtime access checks against the non-transactional app instance 2025-01-17 15:59:39 +02:00
Gani Georgiev c70ca97888 removed unnecessary count 2025-01-17 15:58:57 +02:00
Gani Georgiev 0798b5ccbb updated unmarshaled spelling for consistency 2025-01-12 17:43:16 +02:00
Gani Georgiev e4d637e6e0 added test for partially matched table name/alias as suffix 2025-01-12 13:24:33 +02:00
Gani Georgiev f4108cb354 synced with master 2025-01-12 13:02:51 +02:00
Gani Georgiev 2317695011 bumped app version 2025-01-12 11:40:03 +02:00
Gani Georgiev 8b89bce2a8 [#6281] fixed unique validator error 2025-01-12 11:37:24 +02:00
Gani Georgiev 023428ce8f synced with master 2025-01-11 12:38:11 +02:00
Gani Georgiev 7175992ce4 updated go deps and jsvm types 2025-01-11 12:33:46 +02:00
Gani Georgiev 63fb2b6506 updated README 2025-01-11 12:28:17 +02:00
Gani Georgiev 81f4db855b updated jsvm types 2025-01-10 11:48:09 +02:00
Gani Georgiev e103d987ce soft-deprecated Record.GetUploadedFiles in favour of Record.GetUnsavedFiles 2025-01-10 11:45:55 +02:00
Gani Georgiev 18d0b47aeb synced with master 2025-01-10 09:46:33 +02:00
Gani Georgiev 1e9847e924 reload created or edited records data in the RecordsPicker 2025-01-10 09:41:46 +02:00
Gani Georgiev c1c499fc1f bumped package version 2025-01-10 08:53:42 +02:00
Gani Georgiev accc51bf65 updated Google OAuth2 endpoints 2025-01-09 15:06:45 +02:00
Gani Georgiev 0aa21c1d04 synced with master 2025-01-05 15:10:53 +02:00
Gani Georgiev cad16fac6b [#6229] fixed display fields extraction 2025-01-05 14:33:16 +02:00
Gani Georgiev b150a3a98a upgraded to jwt/v5 2025-01-05 11:18:00 +02:00
Gani Georgiev 41f1ff2b5f updated mime types autocomplete list 2025-01-04 21:53:19 +02:00
Gani Georgiev 1286b59f54 synced with master 2025-01-04 09:45:20 +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 ff3d51ce30 added JSVM new Timezone binding 2025-01-03 17:35:49 +02:00
Gani Georgiev dadbca5248 use the original record id in the update manage rule checks 2025-01-03 17:35:21 +02:00
Gani Georgiev 1e2a923433 added missing time macros in the UI autocomplete 2025-01-02 09:41:50 +02:00
Gani Georgiev 9e46d811d6 fixed changelog typo 2025-01-02 09:15:52 +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 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 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 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 3634fd9c26 synced with master 2024-12-09 08:25:44 +02:00
Gani Georgiev f7c85940c4 synced with master 2024-12-08 14:43:07 +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 7c01441392 added yesterday and tomorrow date filter macros 2024-12-05 13:34:11 +02:00
266 changed files with 16275 additions and 10150 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '>=1.23.4'
go-version: '>=1.23.6'
# 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.
+243 -1
View File
@@ -1,3 +1,245 @@
## v0.26.2
- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
## v0.26.1
- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)).
## v0.26.0
- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)).
As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster.
_Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._
- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_).
- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)).
- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)).
- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads.
_If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._
- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)).
- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._)
## v0.25.9
- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)).
## v0.25.8
- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)).
It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_).
## v0.25.7
- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
## v0.25.6
- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
- Updated npm dependencies.
## v0.25.5
- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
## v0.25.4
- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce.
This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli).
For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo:
```
// go.mod
github.com/aws/aws-sdk-go-v2 v1.36.1
github.com/aws/aws-sdk-go-v2/config v1.28.10
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
// after that run
go clean -modcache && go mod tidy
```
_The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._
## v0.25.3
- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)).
## v0.25.2
- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)).
## v0.25.1
- Fixed the batch API Preview success sample response.
- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
## v0.25.0
- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
_For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
- `/v3/userinfo` auth response changes:
```
meta.rawUser.id => meta.rawUser.sub
meta.rawUser.verified_email => meta.rawUser.email_verified
```
- `/v2/auth` query parameters changes:
If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
_You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
- Upgraded to `golang-jwt/jwt/v5`.
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
## v0.24.4
- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
## v0.24.3
- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
- Reload the created/edited records data in the RecordsPicker UI.
- Updated Go dependencies.
## v0.24.2
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
## v0.24.1
- Added missing time macros in the UI autocomplete.
- Fixed JSVM types for structs and functions with multiple generic parameters.
## v0.24.0
- ⚠️ 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)).
- ⚠️ 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)`.
- Added `@yesterday` and `@tomorrow` datetime filter macros.
- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
- 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._
- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
- 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.
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
## v0.23.12
- 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)).
- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
## 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.10
- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
- 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.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.8
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
@@ -29,7 +271,7 @@
- Other minor fixes (comment typos, JSVM types update).
- Updated Go deps and the min Go releleaser GitHub action version to 1.23.4.
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
## v0.23.4
+31
View File
@@ -2,6 +2,37 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.32
- (_Backported from v0.26.2_) - Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
## v0.22.31
- (_Backported from v0.25.5_) Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
## v0.22.30
- (_Backported from v0.24.4_) Fixed fields extraction for view queries with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
## 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)).
+6 -3
View File
@@ -25,10 +25,13 @@
## API SDK clients
The easiest way to interact with the API is to use one of the official SDK clients:
The easiest way to interact with the PocketBase Web APIs is to use one of the official SDK clients:
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_Browser, Node.js, React Native_)
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_Web, Mobile, Desktop, CLI_)
You could also check the recommendations in https://pocketbase.io/docs/how-to-use/.
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview
+1 -1
View File
@@ -18,7 +18,7 @@ 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(RequireSuperuserAuth())
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(RequireSuperuserAuth())
+16 -2
View File
@@ -10,9 +10,10 @@ import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"gocloud.dev/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
)
func TestBackupsList(t *testing.T) {
@@ -346,6 +347,19 @@ func TestBackupUpload(t *testing.T) {
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "ensure that the default body limit is skipped",
Method: http.MethodPost,
URL: "/api/backups/upload",
Body: bytes.NewBuffer(make([]byte, apis.DefaultMaxBodySize+100)),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
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},
},
}
for _, scenario := range scenarios {
@@ -476,7 +490,7 @@ func TestBackupsDownload(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
+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)
+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)
}
}
+30 -36
View File
@@ -5,37 +5,31 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/fatih/color"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/osutils"
)
// @todo consider combining with the installer specific hooks after refactoring cmd
func loadInstaller(app core.App, dashboardURL string) error {
if !needInstallerSuperuser(app) {
return nil
}
installerRecord, err := findOrCreateInstallerSuperuser(app)
if err != nil {
return err
}
token, err := installerRecord.NewStaticAuthToken(30 * time.Minute)
// 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(dashboardURL, "/"), token)
_ = launchURL(url)
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])
@@ -43,6 +37,23 @@ func loadInstaller(app core.App, dashboardURL string) error {
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,
@@ -65,7 +76,7 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
record = core.NewRecord(col)
record.SetEmail(core.DefaultInstallerEmail)
record.SetPassword(security.RandomString(30))
record.SetRandomPassword()
err = app.Save(record)
if err != nil {
@@ -75,20 +86,3 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
return record, nil
}
func launchURL(url string) error {
if err := is.URL.Validate(url); err != nil {
return err
}
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
case "windows":
// not sure if this is the best command but seems to be the most reliable based on the comments in
// https://stackoverflow.com/questions/3739327/launching-a-website-via-the-windows-commandline#answer-49115945
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
default: // linux, freebsd, etc.
return exec.Command("xdg-open", url).Start()
}
}
+5 -5
View File
@@ -111,7 +111,7 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
//
//nolint:unused
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[*rateLimiter])
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
if !ok || rateLimiters == nil {
return false
}
@@ -146,7 +146,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
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
@@ -198,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
}
@@ -225,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 {
+74 -42
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
@@ -83,11 +84,10 @@ func realtimeConnect(e *core.RequestEvent) error {
Data: []byte(`{"clientId":"` + ce.Client.Id() + `"}`),
}
connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
me.Response.Write([]byte("data:"))
me.Response.Write(me.Message.Data)
me.Response.Write([]byte("\n\n"))
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if connectMsgErr != nil {
@@ -122,11 +122,10 @@ func realtimeConnect(e *core.RequestEvent) error {
msgEvent.Client = ce.Client
msgEvent.Message = &msg
msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
me.Response.Write([]byte("data:"))
me.Response.Write(me.Message.Data)
me.Response.Write([]byte("\n\n"))
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if msgErr != nil {
@@ -353,7 +352,10 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeBroadcastRecord(e.App, "delete", record, true)
// note: use the outside scoped app instance for the access checks so that the API rules
// are performed out of the delete transaction ensuring that they would still work even if
// a cascade-deleted record's API rule relies on an already deleted parent record
err := realtimeBroadcastRecord(e.App, "delete", record, true, app)
if err != nil {
app.Logger().Debug(
"Failed to dry cache record delete",
@@ -372,14 +374,17 @@ func bindRealtimeEvents(app core.App) {
// delete: broadcast
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeBroadcastDryCachedRecord(e.App, "delete", record)
// note: only ensure that it is a collection record
// and don't use realtimeResolveRecord because in case of a
// custom model it'll fail to resolve since the record is already deleted
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil {
err := realtimeBroadcastDryCacheKey(e.App, getDryCacheKey("delete", e.Model))
if err != nil {
app.Logger().Debug(
"Failed to broadcast record delete",
slog.String("id", record.Id),
slog.String("collectionName", record.Collection().Name),
slog.Any("id", e.Model.PK()),
slog.String("collectionName", collection.Name),
slog.String("error", err.Error()),
)
}
@@ -395,7 +400,7 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelErrorEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeUnsetDryCachedRecord(e.App, "delete", record)
err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
if err != nil {
app.Logger().Debug(
"Failed to cleanup after broadcast record delete failure",
@@ -415,7 +420,14 @@ func bindRealtimeEvents(app core.App) {
// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
func realtimeResolveRecord(app core.App, model core.Model, optCollectionType string) *core.Record {
record, _ := model.(*core.Record)
var record *core.Record
switch m := model.(type) {
case *core.Record:
record = m
case core.RecordProxy:
record = m.ProxyRecord()
}
if record != nil {
if optCollectionType == "" || record.Collection().Type == optCollectionType {
return record
@@ -444,14 +456,20 @@ func realtimeResolveRecord(app core.App, model core.Model, optCollectionType str
// realtimeResolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
// This is usually helpful if the provided model is a custom Record model struct.
func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
if record, ok := model.(*core.Record); ok {
collection = record.Collection()
} else {
// check if it is custom Record model struct (ignore "private" tables)
collection, _ = app.FindCachedCollectionByNameOrId(model.TableName())
switch m := model.(type) {
case *core.Record:
return m.Collection()
case core.RecordProxy:
return m.ProxyRecord().Collection()
default:
// check if it is custom Record model struct
collection, err := app.FindCachedCollectionByNameOrId(model.TableName())
if err == nil {
return collection
}
}
return collection
return nil
}
// recordData represents the broadcasted record subscrition message data.
@@ -460,7 +478,11 @@ type recordData struct {
Action string `json:"action"`
}
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool) error {
// Note: the optAccessCheckApp is there in case you want the access check
// to be performed against different db app context (e.g. out of a transaction).
// If set, it is expected that optAccessCheckApp instance is used for read-only operations to avoid deadlocks.
// If not set, it fallbacks to app.
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool, optAccessCheckApp ...core.App) error {
collection := record.Collection()
if collection == nil {
return errors.New("[broadcastRecord] Record collection not set")
@@ -482,10 +504,15 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
(collection.Id + "?"): collection.ListRule,
}
dryCacheKey := action + "/" + record.Id
dryCacheKey := getDryCacheKey(action, record)
group := new(errgroup.Group)
accessCheckApp := app
if len(optAccessCheckApp) > 0 {
accessCheckApp = optAccessCheckApp[0]
}
for _, chunk := range chunks {
group.Go(func() error {
var clientAuth *core.Record
@@ -502,10 +529,6 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
for sub, options := range subs {
// create a clean record copy without expand and unknown fields
// because we don't know yet which exact fields the client subscription has permissions to access
cleanRecord := record.Fresh()
// mock request data
requestInfo := &core.RequestInfo{
Context: core.RequestInfoContextRealtime,
@@ -515,10 +538,14 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
Auth: clientAuth,
}
if !realtimeCanAccessRecord(app, cleanRecord, requestInfo, rule) {
if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
continue
}
// create a clean record copy without expand and unknown fields because we don't know yet
// which exact fields the client subscription requested or has permissions to access
cleanRecord := record.Fresh()
// trigger the enrich hooks
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
// apply expand
@@ -541,7 +568,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
// for auth owner, superuser or manager
if collection.IsAuth() {
if isSameAuth(clientAuth, cleanRecord) ||
realtimeCanAccessRecord(app, cleanRecord, requestInfo, collection.ManageRule) {
realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
cleanRecord.IgnoreEmailVisibility(true)
}
}
@@ -622,15 +649,13 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
return group.Wait()
}
// realtimeBroadcastDryCachedRecord broadcasts all cached record related messages.
func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.Record) error {
// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
func realtimeBroadcastDryCacheKey(app core.App, key string) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
if len(chunks) == 0 {
return nil // no subscribers
}
key := action + "/" + record.Id
group := new(errgroup.Group)
for _, chunk := range chunks {
@@ -659,15 +684,13 @@ func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.
return group.Wait()
}
// realtimeUnsetDryCachedRecord removes the dry cached record related messages.
func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Record) error {
// realtimeUnsetDryCacheKey removes the dry cached key related messages.
func realtimeUnsetDryCacheKey(app core.App, key string) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
if len(chunks) == 0 {
return nil // no subscribers
}
key := action + "/" + record.Id
group := new(errgroup.Group)
for _, chunk := range chunks {
@@ -685,6 +708,15 @@ func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Reco
return group.Wait()
}
func getDryCacheKey(action string, model core.Model) string {
pkStr, ok := model.PK().(string)
if !ok {
pkStr = fmt.Sprintf("%v", model.PK())
}
return action + "/" + model.TableName() + "/" + pkStr
}
func isSameAuth(authA, authB *core.Record) bool {
if authA == nil {
return authB == nil
@@ -722,7 +754,7 @@ func realtimeCanAccessRecord(
return false
}
var exists bool
var exists int
q := app.DB().Select("(1)").
From(record.Collection().Name).
@@ -739,5 +771,5 @@ func realtimeCanAccessRecord(
err = q.Limit(1).Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
+251
View File
@@ -2,10 +2,13 @@ package apis_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -14,6 +17,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRealtimeConnect(t *testing.T) {
@@ -632,3 +636,250 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
}
}
// -------------------------------------------------------------------
var _ core.Model = (*CustomModelResolve)(nil)
type CustomModelResolve struct {
core.BaseModel
tableName string
Created string `db:"created"`
}
func (m *CustomModelResolve) TableName() string {
return m.tableName
}
func TestRealtimeRecordResolve(t *testing.T) {
t.Parallel()
const testCollectionName = "realtime_test_collection"
testRecordId := core.GenerateDefaultRandomId()
client0 := subscriptions.NewDefaultClient()
client0.Subscribe(testCollectionName + "/*")
client0.Discard()
// ---
client1 := subscriptions.NewDefaultClient()
client1.Subscribe(testCollectionName + "/*")
// ---
client2 := subscriptions.NewDefaultClient()
client2.Subscribe(testCollectionName + "/" + testRecordId)
// ---
client3 := subscriptions.NewDefaultClient()
client3.Subscribe("demo1/*")
scenarios := []struct {
name string
op func(testApp core.App) error
expected map[string][]string // clientId -> [events]
}{
{
"core.Record",
func(testApp core.App) error {
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
if err != nil {
return err
}
r := core.NewRecord(c)
r.Id = testRecordId
// create
err = testApp.Save(r)
if err != nil {
return err
}
// update
err = testApp.Save(r)
if err != nil {
return err
}
// delete
err = testApp.Delete(r)
if err != nil {
return err
}
return nil
},
map[string][]string{
client1.Id(): {"create", "update", "delete"},
client2.Id(): {"create", "update", "delete"},
},
},
{
"core.RecordProxy",
func(testApp core.App) error {
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
if err != nil {
return err
}
r := core.NewRecord(c)
proxy := &struct {
core.BaseRecordProxy
}{}
proxy.SetProxyRecord(r)
proxy.Id = testRecordId
// create
err = testApp.Save(proxy)
if err != nil {
return err
}
// update
err = testApp.Save(proxy)
if err != nil {
return err
}
// delete
err = testApp.Delete(proxy)
if err != nil {
return err
}
return nil
},
map[string][]string{
client1.Id(): {"create", "update", "delete"},
client2.Id(): {"create", "update", "delete"},
},
},
{
"custom model struct",
func(testApp core.App) error {
m := &CustomModelResolve{tableName: testCollectionName}
m.Id = testRecordId
// create
err := testApp.Save(m)
if err != nil {
return err
}
// update
m.Created = "123"
err = testApp.Save(m)
if err != nil {
return err
}
// delete
err = testApp.Delete(m)
if err != nil {
return err
}
return nil
},
map[string][]string{
client1.Id(): {"create", "update", "delete"},
client2.Id(): {"create", "update", "delete"},
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
// create new test collection with public read access
testCollection := core.NewBaseCollection(testCollectionName)
testCollection.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true, OnUpdate: true})
testCollection.ListRule = types.Pointer("")
testCollection.ViewRule = types.Pointer("")
err := testApp.Save(testCollection)
if err != nil {
t.Fatal(err)
}
testApp.SubscriptionsBroker().Register(client0)
testApp.SubscriptionsBroker().Register(client1)
testApp.SubscriptionsBroker().Register(client2)
testApp.SubscriptionsBroker().Register(client3)
var wg sync.WaitGroup
var notifications = map[string][]string{}
var mu sync.Mutex
notify := func(clientId string, eventData []byte) {
data := struct{ Action string }{}
_ = json.Unmarshal(eventData, &data)
mu.Lock()
defer mu.Unlock()
if notifications[clientId] == nil {
notifications[clientId] = []string{}
}
notifications[clientId] = append(notifications[clientId], data.Action)
}
wg.Add(1)
go func() {
defer wg.Done()
timeout := time.After(250 * time.Millisecond)
for {
select {
case e, ok := <-client0.Channel():
if ok {
notify(client0.Id(), e.Data)
}
case e, ok := <-client1.Channel():
if ok {
notify(client1.Id(), e.Data)
}
case e, ok := <-client2.Channel():
if ok {
notify(client2.Id(), e.Data)
}
case e, ok := <-client3.Channel():
if ok {
notify(client3.Id(), e.Data)
}
case <-timeout:
return
}
}
}()
err = s.op(testApp)
if err != nil {
t.Fatal(err)
}
wg.Wait()
if len(s.expected) != len(notifications) {
t.Fatalf("Expected %d notified clients, got %d:\n%v", len(s.expected), len(notifications), notifications)
}
for id, events := range s.expected {
if len(events) != len(notifications[id]) {
t.Fatalf("[%s] Expected %d events, got %d:\n%v\n%v", id, len(events), len(notifications[id]), s.expected, notifications)
}
for _, event := range events {
if !slices.Contains(notifications[id], event) {
t.Fatalf("[%s] Missing expected event %q in %v", id, event, notifications[id])
}
}
}
})
}
}
+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))
+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(
+33 -19
View File
@@ -3,20 +3,20 @@ package apis
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"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"
)
@@ -35,6 +35,8 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
fallbackAuthRecord = e.Auth
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
form := new(recordOAuth2LoginForm)
form.collection = collection
if err = e.BindBody(form); err != nil {
@@ -134,13 +136,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
}
meta := struct {
*auth.AuthUser
IsNew bool `json:"isNew"`
}{
AuthUser: e.OAuth2User,
IsNew: e.IsNewRecord,
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
meta := map[string]any{}
rawOAuth2User, err := json.Marshal(e.OAuth2User)
if err != nil {
return err
}
err = json.Unmarshal(rawOAuth2User, &meta)
if err != nil {
return err
}
meta["isNew"] = e.IsNewRecord
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
})
@@ -195,10 +201,20 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
// ensure that username is unique
checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
if checkUnique {
if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
return false // already exist
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
if hasUniqueue {
var expr dbx.Expression
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
} else {
expr = dbx.HashExp{"username": username}
}
var exists int
_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
if exists > 0 {
return false
}
}
@@ -222,12 +238,10 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload = map[string]any{}
}
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]
// assign the OAuth2 user email only if the user hasn't submitted one
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
payload[core.FieldNameEmail] = e.OAuth2User.Email
}
// map known fields (unless the field was explicitly submitted as part of CreateData)
@@ -292,7 +306,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
}
+155 -12
View File
@@ -14,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils"
"golang.org/x/oauth2"
)
@@ -174,6 +175,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if err := app.Save(ea); err != nil {
t.Fatal(err)
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextOAuth2 {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -292,6 +307,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test2@example.com"`,
`"id":"oap640cot4yru2s"`,
`"id":"test_id"`,
@@ -383,6 +399,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -488,6 +505,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -558,12 +576,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{
@@ -592,6 +619,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test_oauth2@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -697,6 +725,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -786,6 +815,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":true`,
`"email":""`,
`"id":"test_id"`,
`"verified":true`,
@@ -867,7 +897,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"verified":{"code":"validation_values_mismatch"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
`"email":`, // ignored because the record validator never ran
`"rel":`, // ignored because the record validator never ran
`"file":`, // ignored because the record validator never ran
},
@@ -920,12 +950,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{"code":"validation_is_email"`,
`"rel":{"code":"validation_missing_rel_records"`,
`"file":{"code":"validation_invalid_file"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
@@ -939,7 +967,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (valid create data)",
Name: "creating user (valid create data with empty submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -947,8 +975,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "invalid",
"email": "",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
"name": "test_name",
"username": "test_username",
"rel": "0yxhwia2amd8gec"
@@ -982,6 +1012,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":""`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1018,6 +1049,105 @@ 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 (valid create data with non-empty valid submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "test_create@example.com",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
"name": "test_name",
"username": "test_username",
"rel": "0yxhwia2amd8gec"
}
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
usersCol, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{
Id: "test_id",
Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(usersCol); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test_create@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
`"username":"test_username"`,
`"verified":false`,
`"rel":"0yxhwia2amd8gec"`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordCreateRequest": 1,
"OnRecordEnrich": 2, // the auth response and from the create request
// ---
"OnModelCreate": 3, // record + authOrigins + externalAuths
"OnModelCreateExecute": 3,
"OnModelAfterCreateSuccess": 3,
"OnRecordCreate": 3,
"OnRecordCreateExecute": 3,
"OnRecordAfterCreateSuccess": 3,
// ---
"OnModelValidate": 3,
"OnRecordValidate": 3,
},
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)",
@@ -1071,6 +1201,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1153,6 +1284,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1189,7 +1321,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (with mapped OAuth2 fields and avatarURL->non-file field)",
Name: "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -1209,7 +1341,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
AuthUser: &auth.AuthUser{
Id: "oauth2_id",
Email: "oauth2@example.com",
Username: "oauth2_username",
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
Name: "oauth2_name",
AvatarURL: server.URL + "/oauth2_avatar.png",
},
@@ -1235,9 +1367,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"username":"oauth2_username"`,
`"username":"tESt2_username"`,
`"name":"http://127.`,
`"verified":true`,
`"avatar":""`,
@@ -1273,7 +1406,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (with mapped OAuth2 fields and duplicated username)",
Name: "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -1293,13 +1426,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
AuthUser: &auth.AuthUser{
Id: "oauth2_id",
Email: "oauth2@example.com",
Username: "test2_username",
Username: "tESt2_username",
Name: "oauth2_name",
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// make the username index case-insensitive to ensure that case-insensitive match is used
index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
if ok {
index.Columns[0].Collate = "nocase"
usersCol.RemoveIndex(index.IndexName)
usersCol.Indexes = append(usersCol.Indexes, index.Build())
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
@@ -1317,6 +1458,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1398,6 +1540,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
+3 -6
View File
@@ -26,6 +26,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOTP)
event := new(core.RecordAuthWithOTPRequestEvent)
event.RequestEvent = e
event.Collection = collection
@@ -85,12 +87,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
if err != nil {
return err
}
return nil
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
})
}
+14
View File
@@ -269,6 +269,20 @@ func TestRecordAuthWithOTP(t *testing.T) {
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextOTP {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOTP, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
+42 -4
View File
@@ -3,10 +3,14 @@ package apis
import (
"database/sql"
"errors"
"slices"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -28,16 +32,18 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextPasswordAuth)
var foundRecord *core.Record
var foundErr error
if form.IdentityField != "" {
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, form.IdentityField, form.Identity)
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
} else {
// prioritize email lookup
isEmail := is.EmailFormat.Validate(form.Identity) == nil
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
foundRecord, foundErr = e.App.FindAuthRecordByEmail(collection.Id, form.Identity)
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
}
// search by the other identity fields
@@ -47,7 +53,7 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
continue // no need to search by the email field if it is not an email
}
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, name, form.Identity)
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
if foundErr == nil {
break
}
@@ -92,6 +98,38 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
return validation.ValidateStruct(form,
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
validation.Field(&form.IdentityField, validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...)),
validation.Field(
&form.IdentityField,
validation.Length(1, 255),
validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...),
),
)
}
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
return nil, errors.New("invalid identity field " + field)
}
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field)
if !ok {
return nil, errors.New("missing " + field + " unique index constraint")
}
var expr dbx.Expression
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search
expr = dbx.NewExp("[["+field+"]] = {:identity} COLLATE NOCASE", dbx.Params{"identity": value})
} else {
expr = dbx.HashExp{field: value}
}
record := &core.Record{}
err := app.RecordQuery(collection).AndWhere(expr).Limit(1).One(record)
if err != nil {
return nil, err
}
return record, nil
}
+193
View File
@@ -8,11 +8,38 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
)
func TestRecordAuthWithPassword(t *testing.T) {
t.Parallel()
updateIdentityIndex := func(collectionIdOrName string, fieldCollateMap map[string]string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
collection, err := app.FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
for column, collate := range fieldCollateMap {
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, column)
if !ok {
t.Fatalf("Missing unique identityField index for column %q", column)
}
index.Columns[0].Collate = collate
collection.RemoveIndex(index.IndexName)
collection.Indexes = append(collection.Indexes, index.Build())
}
err = app.Save(collection)
if err != nil {
t.Fatalf("Failed to update identityField index: %v", err)
}
}
}
scenarios := []tests.ApiScenario{
{
Name: "disabled password auth",
@@ -99,6 +126,21 @@ func TestRecordAuthWithPassword(t *testing.T) {
"identity":"test@example.com",
"password":"1234567890"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextPasswordAuth {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextPasswordAuth, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
@@ -164,6 +206,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnMailerRecordAuthAlertSend": 1,
},
},
{
Name: "unknown explicit identityField",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identityField": "created",
"identity":"test@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"identityField":{"code":"validation_in_invalid"`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid identity field and valid password with mismatched explicit identityField",
Method: http.MethodPost,
@@ -440,6 +498,141 @@ func TestRecordAuthWithPassword(t *testing.T) {
},
},
// case sensitivity checks
// -----------------------------------------------------------
{
Name: "with explicit identityField (case-sensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identityField": "username",
"identity":"Clients57772",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": ""}),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
},
},
{
Name: "with explicit identityField (case-insensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identityField": "username",
"identity":"Clients57772",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"clients57772"`,
`"token":`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// authOrigin track
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
"OnMailerSend": 1,
"OnMailerRecordAuthAlertSend": 1,
},
},
{
Name: "without explicit identityField and non-email field (case-insensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"Clients57772",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"clients57772"`,
`"token":`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// authOrigin track
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
"OnMailerSend": 1,
"OnMailerRecordAuthAlertSend": 1,
},
},
{
Name: "without explicit identityField and email field (case-insensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"tESt@example.com",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"email": "nocase"}),
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"clients57772"`,
`"token":`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// authOrigin track
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
"OnMailerSend": 1,
"OnMailerRecordAuthAlertSend": 1,
},
},
// rate limit checks
// -----------------------------------------------------------
{
+135 -53
View File
@@ -13,9 +13,11 @@ import (
"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
@@ -57,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))
@@ -92,9 +98,9 @@ func recordsList(e *core.RequestEvent) error {
// 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 possibity of eventual
// 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 tradeoff between performance, usability and correctness.
// 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.
@@ -107,7 +113,7 @@ func recordsList(e *core.RequestEvent) error {
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(100)
randomizedThrottle(150)
}
return e.JSON(http.StatusOK, e.Result)
@@ -218,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
@@ -228,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)
@@ -241,57 +264,74 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
// temporary save the record and check it against the create and manage rules
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
// temporary grant manager access level
form.GrantManagerAccess()
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 int
err = ruleQuery.Limit(1).Row(&exists)
if err != nil || exists == 0 {
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)
@@ -411,7 +451,13 @@ 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{
// note: use the original record id and not e.Record.Id because the record validations because may get overwritten
e.Collection.Name + ".id": e.Record.LastSavedPK(),
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
form.GrantManagerAccess()
}
@@ -644,3 +690,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 int
err = query.Limit(1).Row(&exists)
return err == nil && exists > 0
}
+51 -5
View File
@@ -259,6 +259,47 @@ 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,
@@ -1747,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,
},
},
{
+25 -37
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"
)
@@ -144,7 +146,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
return true, err
}
var exists bool
var exists int
query := e.App.RecordQuery(record.Collection()).
Select("(1)").
@@ -163,7 +165,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
return true, err
}
return exists, nil
return exists > 0, nil
}
// checkMFA handles any MFA auth checks that needs to be performed for the specified request event.
@@ -479,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."}
@@ -603,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
}
}
+44 -30
View File
@@ -4,7 +4,6 @@ import (
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"net"
"net/http"
@@ -17,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"
@@ -143,9 +143,9 @@ func Serve(app core.App, config ServeConfig) error {
NextProtos: []string{acme.ALPNProto},
},
// higher defaults to accommodate large file uploads/downloads
WriteTimeout: 3 * time.Minute,
ReadTimeout: 3 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute,
ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 1 * time.Minute,
Addr: mainAddr,
BaseContext: func(l net.Listener) context.Context {
return baseCtx
@@ -158,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
@@ -204,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()
@@ -213,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 {
@@ -224,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
@@ -238,17 +262,6 @@ func Serve(app core.App, config ServeConfig) error {
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
}
schema := "http"
addr := server.Addr
if config.HttpsAddr != "" {
schema = "https"
if len(config.CertificateDomains) > 0 {
addr = config.CertificateDomains[0]
}
}
baseURL := fmt.Sprintf("%s://%s", schema, addr)
dashboardURL := fmt.Sprintf("%s/_", baseURL)
if config.ShowStartBanner {
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
@@ -262,16 +275,9 @@ func Serve(app core.App, config ServeConfig) error {
regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/", dashboardURL))
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
}
go func() {
installerErr := loadInstaller(app, dashboardURL)
if installerErr != nil {
app.Logger().Warn("Failed to initialize installer", "error", installerErr)
}
}()
var serveErr error
if config.HttpsAddr != "" {
if config.HttpAddr != "" {
@@ -280,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
@@ -292,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
}
+30 -11
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
@@ -323,10 +323,10 @@ type App interface {
// FindLogById finds a single Log entry by its id.
FindLogById(id string) (*Log, error)
// LogsStatsItem defines the total number of logs for a specific time period.
// LogsStatsItem returns hourly grouped logs statistics.
LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error)
// DeleteOldLogs delete all requests that are created before createdBefore.
// DeleteOldLogs delete all logs that are created before createdBefore.
DeleteOldLogs(createdBefore time.Time) error
// ---------------------------------------------------------------
@@ -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,
+24 -17
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,
@@ -532,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
}
@@ -601,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")
@@ -1131,26 +1139,25 @@ func (app *BaseApp) initDataDB() error {
return nil
}
var sqlLogReplacements = map[string]string{
"{{": "`",
"}}": "`",
"[[": "`",
"]]": "`",
"<nil>": "NULL",
var sqlLogReplacements = []struct {
pattern *regexp.Regexp
replacement string
}{
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
{regexp.MustCompile(`([^'"])\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])`), "`$1"},
{regexp.MustCompile(`([^'"])\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])`), "`$1"},
{regexp.MustCompile(`<nil>`), "NULL"},
}
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
// (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string {
sql = sqlLogPrefixedTableIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
sql = sqlLogPrefixedColumnIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
for old, new := range sqlLogReplacements {
sql = strings.ReplaceAll(sql, old, new)
for _, item := range sqlLogReplacements {
sql = item.pattern.ReplaceAllString(sql, item.replacement)
}
return sql
+64 -39
View File
@@ -142,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")
@@ -162,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
@@ -170,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)
}
@@ -257,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
+4 -4
View File
@@ -762,9 +762,9 @@ func (c *Collection) updateGeneratedIdIfExists(app App) {
// add a number to the current id (if already exists)
for i := 2; i < 1000; i++ {
var exists bool
var exists int
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
if !exists {
if exists == 0 {
break
}
newId = c.idChecksum() + strconv.Itoa(i)
@@ -989,7 +989,7 @@ func (c *Collection) initTokenKeyField() {
}
// ensure that there is a unique index for the field
if !dbutils.HasSingleColumnUniqueIndex(FieldNameTokenKey, c.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
c.fieldIndexName(FieldNameTokenKey),
@@ -1015,7 +1015,7 @@ func (c *Collection) initEmailField() {
}
// ensure that there is a unique index for the email field
if !dbutils.HasSingleColumnUniqueIndex(FieldNameEmail, c.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
c.fieldIndexName(FieldNameEmail),
+68 -25
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/pocketbase/dbx"
@@ -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 [BaseApp.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 total int
return query.Row(&total) == nil && total == 0
}
// 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()
+2 -2
View File
@@ -456,7 +456,7 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
SetParams(map[string]any{"fieldName": name})
}
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
SetParams(map[string]any{"fieldName": name})
}
@@ -666,7 +666,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
if cv.new.IsAuth() {
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
for _, name := range requiredNames {
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
return validation.NewError(
"validation_missing_required_unique_index",
`Missing required unique index for field "{{.fieldName}}".`,
+4 -5
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
@@ -116,8 +116,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
deleteErr := app.OnModelDelete().Trigger(event, func(e *ModelEvent) error {
pk := cast.ToString(e.Model.LastSavedPK())
if cast.ToString(pk) == "" {
if pk == "" {
return errors.New("the model can be deleted only if it is existing and has a non-empty primary key")
}
@@ -483,7 +482,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
return validation.NewError("validation_invalid_collection", "Missing or invalid collection.")
}
var exists bool
var exists int
rowErr := app.DB().Select("(1)").
From(collection.Name).
@@ -491,7 +490,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
Limit(1).
Row(&exists)
if rowErr != nil || !exists {
if rowErr != nil || exists == 0 {
return validation.NewError("validation_invalid_record", "Missing or invalid record.")
}
+2 -2
View File
@@ -108,7 +108,7 @@ func (app *BaseApp) AuxHasTable(tableName string) bool {
}
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
var exists bool
var exists int
err := db.Select("(1)").
From("sqlite_schema").
@@ -117,7 +117,7 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
Limit(1).
Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
// Vacuum executes VACUUM on the current app.DB() instance
+4 -2
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()
@@ -154,8 +154,10 @@ const (
RequestInfoContextExpand = "expand"
RequestInfoContextRealtime = "realtime"
RequestInfoContextProtectedFile = "protectedFile"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextBatch = "batch"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextOTP = "otp"
RequestInfoContextPasswordAuth = "password"
)
// RequestInfo defines a HTTP request data struct, usually used
+16
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
}
// -------------------------------------------------------------------
+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"
+2 -2
View File
@@ -47,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"`
@@ -148,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 {
+10 -3
View File
@@ -5,6 +5,7 @@ import (
"database/sql/driver"
"errors"
"fmt"
"log"
"regexp"
"strings"
@@ -94,7 +95,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 +224,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),
@@ -637,7 +638,13 @@ func (f *FileField) FindGetter(key string) GetterFunc {
return func(record *Record) any {
return record.GetRaw(f.Name)
}
case f.Name + ":unsaved":
return func(record *Record) any {
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
}
case f.Name + ":uploaded":
// deprecated
log.Println("[file field getter] please replace :uploaded with :unsaved")
return func(record *Record) any {
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
}
+46 -2
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 {
@@ -628,8 +672,8 @@ func TestFileFieldFindGetter(t *testing.T) {
`["300_UhLKX91HVb.png",{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
{
"uploaded",
field.GetName() + ":uploaded",
"unsaved",
field.GetName() + ":unsaved",
true,
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
+2 -2
View File
@@ -51,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"`
@@ -181,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 {
+5 -5
View File
@@ -193,14 +193,14 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
//
// (@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
var exists int
err := app.DB().
Select("(1)").
From(record.TableName()).
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": strings.ToLower(newVal)})).
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
Limit(1).
Row(&exists)
if exists || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
if exists > 0 || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
}
}
@@ -259,8 +259,8 @@ 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.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)),
+44
View File
@@ -430,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 {
+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) {
+2 -2
View File
@@ -34,7 +34,7 @@ type LogsStatsItem struct {
Total int `db:"total" json:"total"`
}
// LogsStats returns hourly grouped requests logs statistics.
// LogsStats returns hourly grouped logs statistics.
func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
result := []*LogsStatsItem{}
@@ -51,7 +51,7 @@ func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
return result, err
}
// DeleteOldLogs delete all requests that are created before createdBefore.
// DeleteOldLogs delete all logs that are created before createdBefore.
//
// For better performance the logs delete is executed as plain SQL statement,
// aka. no delete model hook events will be fired.
+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)
}
+9 -13
View File
@@ -5,9 +5,9 @@ import (
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cast"
)
@@ -80,15 +80,11 @@ func (r *MigrationsRunner) Run(args ...string) error {
return err
}
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf(
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
strings.Join(names, "\n"),
toRevertCount,
),
}
survey.AskOne(prompt, &confirm)
confirm := osutils.YesNoPrompt(fmt.Sprintf(
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
strings.Join(names, "\n"),
toRevertCount,
), false)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
@@ -267,15 +263,15 @@ func (r *MigrationsRunner) initMigrationsTable() error {
}
func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool {
var exists bool
var exists int
err := txApp.DB().Select("count(*)").
err := txApp.DB().Select("(1)").
From(r.tableName).
Where(dbx.HashExp{"file": file}).
Limit(1).
Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error {
+3 -28
View File
@@ -200,38 +200,13 @@ func TestMigrationsRunnerRemoveMissingAppliedMigrations(t *testing.T) {
}
func isMigrationApplied(app core.App, file string) bool {
var exists bool
var exists int
err := app.DB().Select("count(*)").
err := app.DB().Select("(1)").
From(core.DefaultMigrationsTable).
Where(dbx.HashExp{"file": file}).
Limit(1).
Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
// // -------------------------------------------------------------------
// type testDB struct {
// *dbx.DB
// CalledQueries []string
// }
// // NB! Don't forget to call `db.Close()` at the end of the test.
// func createTestDB() (*testDB, error) {
// sqlDB, err := sql.Open("sqlite", ":memory:")
// if err != nil {
// return nil, err
// }
// db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")}
// db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
// db.CalledQueries = append(db.CalledQueries, sql)
// }
// db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
// db.CalledQueries = append(db.CalledQueries, sql)
// }
// return &db, nil
// }
+1 -1
View File
@@ -119,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)
}
+42 -2
View File
@@ -4,12 +4,14 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -18,6 +20,7 @@ const (
eachModifier string = "each"
issetModifier string = "isset"
lengthModifier string = "length"
lowerModifier string = "lower"
)
// ensure that `search.FieldResolver` interface is implemented
@@ -47,6 +50,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 +216,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 + "}",
@@ -271,6 +301,13 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
return mapVal(m, keys...)
case mapExtractor:
return mapVal(m.AsMap(), keys...)
case types.JSONRaw:
var raw any
err := json.Unmarshal(m, &raw)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal raw JSON in order extract nested value from: %w", err)
}
return extractNestedVal(raw, keys...)
// slices
case []string:
@@ -309,6 +346,8 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
return arrVal(extracted, keys...)
case []any:
return arrVal(m, keys...)
case []types.JSONRaw:
return arrVal(m, keys...)
default:
return nil, fmt.Errorf("expected map or array, got %#v", rawData)
}
@@ -355,7 +394,8 @@ func splitModifier(combined string) (string, string, error) {
switch parts[1] {
case issetModifier,
eachModifier,
lengthModifier:
lengthModifier,
lowerModifier:
return parts[0], parts[1], nil
}
+52 -8
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
@@ -456,16 +479,28 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
}
return nil, fmt.Errorf("missing back relation field %q", parts[2])
}
if backField.Type() != FieldTypeRelation {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
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())
}
if backRelField.CollectionId != collection.Id {
return nil, fmt.Errorf("invalid back relation field %q collection reference", backField.GetName())
// https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("invalid collection reference of a back relation field %q", backField.GetName())
}
// join the back relation to the main query
@@ -478,7 +513,8 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
isBackRelMultiple := backRelField.IsMultiple()
if !isBackRelMultiple {
// additionally check if the rel field has a single column unique index
isBackRelMultiple = !dbutils.HasSingleColumnUniqueIndex(backRelField.Name, backCollection.Indexes)
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
isBackRelMultiple = !hasUniqueIndex
}
if !isBackRelMultiple {
@@ -713,11 +749,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 +779,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
}
File diff suppressed because one or more lines are too long
+38 -20
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"slices"
"sort"
@@ -37,9 +38,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
@@ -537,8 +538,8 @@ 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)),
}
@@ -681,7 +682,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)
@@ -833,6 +834,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
@@ -969,20 +974,20 @@ func (m *Record) GetStringSlice(key string) []string {
return list.ToUniqueStringSlice(m.Get(key))
}
// GetUploadedFiles returns the uploaded files for the provided "file" field key,
// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
// (aka. the current [*filesytem.File] values) so that you can apply further
// validations or modifications (including changing the file name or content before persisting).
//
// Example:
//
// files := record.GetUploadedFiles("documents")
// files := record.GetUnsavedFiles("documents")
// for _, f := range files {
// f.Name = "doc_" + f.Name // add a prefix to each file name
// }
// app.Save(record) // the files are pointers so the applied changes will transparently reflect on the record value
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
if !strings.HasSuffix(key, ":uploaded") {
key += ":uploaded"
func (m *Record) GetUnsavedFiles(key string) []*filesystem.File {
if !strings.HasSuffix(key, ":unsaved") {
key += ":unsaved"
}
values, _ := m.Get(key).([]*filesystem.File)
@@ -990,6 +995,12 @@ func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
return values
}
// Deprecated: replaced with GetUnsavedFiles.
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
log.Println("Please replace GetUploadedFiles with GetUnsavedFiles")
return m.GetUnsavedFiles(key)
}
// Retrieves the "key" json field value and unmarshals it into "result".
//
// Example
@@ -1409,12 +1420,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.
@@ -1452,7 +1469,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
}
@@ -1491,12 +1508,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)
}
}
+43 -28
View File
@@ -1013,7 +1013,7 @@ func TestRecordGetStringSlice(t *testing.T) {
}
}
func TestRecordGetUploadedFiles(t *testing.T) {
func TestRecordGetUnsavedFiles(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
@@ -1054,14 +1054,14 @@ func TestRecordGetUploadedFiles(t *testing.T) {
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
{
"files:uploaded",
"files:unsaved",
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) {
v := record.GetUploadedFiles(s.key)
v := record.GetUnsavedFiles(s.key)
raw, err := json.Marshal(v)
if err != nil {
@@ -1822,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) {
+18 -3
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
@@ -527,20 +528,34 @@ func (app *BaseApp) FindAuthRecordByToken(token string, validTypes ...string) (*
// FindAuthRecordByEmail finds the auth record associated with the provided email.
//
// The email check would be case-insensitive if the related collection
// email unique index has COLLATE NOCASE specified for the email column.
//
// Returns an error if it is not an auth collection or the record is not found.
func (app *BaseApp) FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error) {
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
if err != nil {
return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
}
if !collection.IsAuth() {
return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
}
record := &Record{}
var expr dbx.Expression
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, FieldNameEmail)
if ok && strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search
expr = dbx.NewExp("[["+FieldNameEmail+"]] = {:email} COLLATE NOCASE", dbx.Params{"email": email})
} else {
expr = dbx.HashExp{FieldNameEmail: email}
}
err = app.RecordQuery(collection).
AndWhere(dbx.HashExp{FieldNameEmail: email}).
AndWhere(expr).
Limit(1).
One(record)
if err != nil {
@@ -584,7 +599,7 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
return true, nil
}
var exists bool
var exists int
query := app.RecordQuery(record.Collection()).
Select("(1)").
@@ -603,5 +618,5 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
return false, err
}
return exists, nil
return exists > 0, nil
}
+1 -1
View File
@@ -143,7 +143,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
MaxSelect: 2147483647,
CollectionId: indirectRel.Id,
}
if dbutils.HasSingleColumnUniqueIndex(indirectRelField.GetName(), indirectRel.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(indirectRel.Indexes, indirectRelField.GetName()); ok {
relField.MaxSelect = 1
}
relCollection = indirectRel
+33 -9
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -966,23 +967,46 @@ func TestFindAuthRecordByToken(t *testing.T) {
func TestFindAuthRecordByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
email string
nocaseIndex bool
expectError bool
}{
{"missing", "test@example.com", true},
{"demo2", "test@example.com", true},
{"users", "missing@example.com", true},
{"users", "test@example.com", false},
{"clients", "test2@example.com", false},
{"missing", "test@example.com", false, true},
{"demo2", "test@example.com", false, true},
{"users", "missing@example.com", false, true},
{"users", "test@example.com", false, false},
{"clients", "test2@example.com", false, false},
// case-insensitive tests
{"clients", "TeSt2@example.com", false, true},
{"clients", "TeSt2@example.com", true, false},
}
for _, s := range scenarios {
t.Run(fmt.Sprintf("%s_%s", s.collectionIdOrName, s.email), func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.FindCollectionByNameOrId(s.collectionIdOrName)
if collection != nil {
emailIndex, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, core.FieldNameEmail)
if ok {
if s.nocaseIndex {
emailIndex.Columns[0].Collate = "nocase"
} else {
emailIndex.Columns[0].Collate = ""
}
collection.RemoveIndex(emailIndex.IndexName)
collection.Indexes = append(collection.Indexes, emailIndex.Build())
err := app.Save(collection)
if err != nil {
t.Fatalf("Failed to update email index: %v", err)
}
}
}
record, err := app.FindAuthRecordByEmail(s.collectionIdOrName, s.email)
hasErr := err != nil
@@ -994,7 +1018,7 @@ func TestFindAuthRecordByEmail(t *testing.T) {
return
}
if record.Email() != s.email {
if !strings.EqualFold(record.Email(), s.email) {
t.Fatalf("Expected record with email %s, got %s", s.email, record.Email())
}
})
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/tools/security"
)
+6 -4
View File
@@ -50,7 +50,6 @@ func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []stri
return err
}
//
if _, ok := err.(validation.Errors); ok {
return err
}
@@ -59,12 +58,15 @@ func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []stri
// check for unique constraint failure
if strings.Contains(msg, "unique constraint failed") {
// note: extra space to unify multi-columns lookup
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ") + " "
normalizedErrs := validation.Errors{}
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
for _, name := range fieldNames {
// blank space to unify multi-columns lookup
if strings.Contains(msg+" ", strings.ToLower(tableOrAlias+"."+name)) {
// note: extra spaces to exclude table name with suffix matching the current one
// OR other fields starting with the current field name
if strings.Contains(msg, strings.ToLower(" "+tableOrAlias+"."+name+" ")) {
normalizedErrs[name] = validation.NewError("validation_not_unique", "Value must be unique")
}
}
+14
View File
@@ -78,6 +78,13 @@ func TestNormalizeUniqueIndexError(t *testing.T) {
[]string{"a", "b"},
nil,
},
{
"unique index error with table name suffix matching the specified one",
errors.New("UNIQUE constraint failed for fields test_suffix.a,test_suffix.b"),
"suffix",
[]string{"a", "b", "c"},
nil,
},
{
"unique index error but mismatched fields",
errors.New("UNIQUE constraint failed for fields test.a,test.b"),
@@ -92,6 +99,13 @@ func TestNormalizeUniqueIndexError(t *testing.T) {
[]string{"a", "b", "c"},
[]string{"a", "b"},
},
{
"unique index error with matching table name and field starting with the name of another non-unique field",
errors.New("UNIQUE constraint failed for fields test.a_2,test.c"),
"test",
[]string{"a", "a_2", "c"},
[]string{"a_2", "c"},
},
}
for _, s := range scenarios {
+10 -8
View File
@@ -437,9 +437,11 @@ func getQueryTableInfo(app App, selectQuery string) ([]*TableInfoRow, error) {
// Raw query identifiers parser
// -------------------------------------------------------------------
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
var (
joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]*?\*\/)|(--.+$)`)
)
type identifier struct {
original string
@@ -453,9 +455,9 @@ type identifiersParser struct {
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
str = commentsReplaceRegex.ReplaceAllString(str, " ")
str = joinReplaceRegex.ReplaceAllString(str, " __pb_join__ ")
str = discardReplaceRegex.ReplaceAllString(str, " __pb_discard__ ")
tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t')
@@ -490,7 +492,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
skip = false
partType = "from"
activeBuilder = &fromParts
case "_join_":
case "__pb_join__":
skip = false
// the previous part was also a join
@@ -500,7 +502,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
partType = "join"
activeBuilder = &joinParts
case "_discard_":
case "__pb_discard__":
// skip following tokens
skip = true
default:
+14 -4
View File
@@ -246,11 +246,21 @@ func TestCreateViewFields(t *testing.T) {
`
select
-- test single line
id,
text,
demo1.id,
demo1.text,
/* multi
line comment */
url, created, updated from demo1
* line comment block */
demo1.url, demo1.created, demo1.updated from/* inline comment block with no spaces between the identifiers */demo1
-- comment before join
join demo2 ON (
-- comment inside join
demo2.id = demo1.id
)
-- comment before where
where (
-- comment inside where
demo2.id = demo1.id
)
`,
false,
map[string]string{
+2 -2
View File
@@ -36,7 +36,7 @@ func main() {
&hooksWatch,
"hooksWatch",
true,
"auto restart the app on pb_hooks file change",
"auto restart the app on pb_hooks file change; it has no effect on Windows",
)
var hooksPool int
@@ -76,7 +76,7 @@ func main() {
&indexFallback,
"indexFallback",
true,
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
"fallback the request to index.html on missing static path, e.g. when pretty urls are used with SPA",
)
app.RootCmd.ParseFlags(os.Args[1:])
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/core"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"encoding/pem"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
+14 -14
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),
),
@@ -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())
+26 -63
View File
@@ -1,87 +1,50 @@
module github.com/pocketbase/pocketbase
go 1.23
go 1.23.0
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go-v2 v1.32.6
github.com/aws/aws-sdk-go-v2/config v1.28.6
github.com/aws/aws-sdk-go-v2/credentials v1.17.47
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0
github.com/aws/smithy-go v1.22.1
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c
github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.7
github.com/gabriel-vasile/mimetype v1.4.8
github.com/ganigeorgiev/fexpr v0.4.1
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75
github.com/spf13/cast v1.7.0
github.com/spf13/cobra v1.8.1
gocloud.dev v0.40.0
golang.org/x/crypto v0.31.0
golang.org/x/net v0.32.0
golang.org/x/oauth2 v0.24.0
golang.org/x/sync v0.10.0
modernc.org/sqlite v1.34.2
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.36.0
golang.org/x/net v0.37.0
golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.12.0
modernc.org/sqlite v1.36.1
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/google/pprof v0.0.0-20250315033105-103756e64e1d // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.8.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.28.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.211.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/grpc v1.69.0 // indirect
google.golang.org/protobuf v1.35.2 // indirect
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 // indirect
modernc.org/libc v1.61.4 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
)
+67 -301
View File
@@ -1,372 +1,138 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.12.1 h1:n2Bj25BUMM0nvE9D2XLTiImanwZhO3DkfWSYS/SAJP4=
cloud.google.com/go/auth v0.12.1/go.mod h1:BFMu+TNpF3DmvfBO9ClqTR/SiqVIm7LukKF9mbendF4=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3 h1:MXsAuToxwsTn5BEEYm2DheqIiC4jWGmkEJ1uy+KFhvQ=
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU=
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg=
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0 h1:jTwdYTGERaZ/3+glBUVQZV2NwGodd9HlkXJbTBUPLLo=
github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20250315033105-103756e64e1d h1:tx51Lf+wdE+aavqH8TcPJoCjTf4cE8hrMzROghCely0=
github.com/google/pprof v0.0.0-20250315033105-103756e64e1d/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75 h1:XSbmekxgmbI2uPrre/nkCz7y8VsV652TPb3hAYzPb74=
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119 h1:TjQtEReJDTpvlNFTRjuHvPQpJHAeJdcQF130eCAAT/o=
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ=
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.69.0 h1:quSiOM1GJPmPH5XtU+BCoVXcDVJJAzNcoyfC2cCjGkI=
google.golang.org/grpc v1.69.0/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc=
modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 h1:ovz6yUKX71igz2yvk4NpiCL5fvdjZAI+DhuDEGx1xyU=
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA=
modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ=
modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+1 -1
View File
@@ -1,4 +1,4 @@
// Package mails implements various helper methods for sending user and admin
// Package mails implements various helper methods for sending common
// emails like forgotten password, verification, etc.
package mails
+2 -2
View File
@@ -11,11 +11,11 @@ import (
// note: this migration will be deleted in future version
func collectionIdChecksum(typ, name string) string {
return "pbc_" + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(typ+name))))
return "pbc_" + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(typ+name))), 10)
}
func fieldIdChecksum(typ, name string) string {
return typ + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(name))))
return typ + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(name))), 10)
}
// normalize system collection and field ids
+83
View File
@@ -0,0 +1,83 @@
package pocketbase
import (
"fmt"
"log/slog"
"runtime/debug"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/core"
)
const (
expectedDriverVersion = "v1.36.1"
expectedLibcVersion = "v1.61.13"
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
// It could be used for removing/unbinding the hook if you don't want the checks.
ModerncDepsCheckHookId = "pbModerncDepsCheck"
)
// checkModerncDeps checks whether the current binary was buit with the
// expected and tested modernc driver dependencies.
//
// This is needed because modernc.org/libc doesn't follow semantic versioning
// and using a version different from the one in the go.mod of modernc.org/sqlite
// could have unintended side-effects and cause obscure build and runtime bugs
// (https://github.com/pocketbase/pocketbase/issues/6136).
func checkModerncDeps(app core.App) {
info, ok := debug.ReadBuildInfo()
if !ok {
return // no build info (probably compiled without module support)
}
var driverVersion, libcVersion string
for _, dep := range info.Deps {
switch dep.Path {
case "modernc.org/libc":
libcVersion = dep.Version
case "modernc.org/sqlite":
driverVersion = dep.Version
}
// no need to further search if both deps are located
if driverVersion != "" && libcVersion != "" {
break
}
}
// not using the default driver
if driverVersion == "" {
return
}
var msg string
if driverVersion != expectedDriverVersion {
msg = fmt.Sprintf(
"You are using modernc.org/sqlite %s which differs from the expected and tested %s.\n"+
"Make sure to either manually update in your go.mod the dependency version to the expected one OR if you want to keep yours "+
"ensure that its indirect modernc.org/libc dependency has the same version as in the https://gitlab.com/cznic/sqlite/-/blob/master/go.mod, "+
"otherwise it could result in unexpected build or runtime errors.",
driverVersion,
expectedDriverVersion,
)
app.Logger().Warn(msg, slog.String("current", driverVersion), slog.String("expected", expectedDriverVersion))
} else if libcVersion != expectedLibcVersion {
msg = fmt.Sprintf(
"You are using modernc.org/libc %s which differs from the expected and tested %s.\n"+
"Please update your go.mod and manually set modernc.org/libc to %s, otherwise it could result in unexpected build or runtime errors "+
"(you may have to also run 'go clean -modcache' to clear the cache if the warning persists).",
libcVersion,
expectedLibcVersion,
expectedLibcVersion,
)
app.Logger().Warn(msg, slog.String("current", libcVersion), slog.String("expected", expectedLibcVersion))
}
// ensure that the message is printed to the default stderr too
// (when in dev mode this is not needed because we print all logs)
if msg != "" && !app.IsDev() {
color.Yellow("\nWARN " + msg + "\n\n")
}
}
+2 -6
View File
@@ -20,10 +20,10 @@ import (
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cobra"
)
@@ -121,11 +121,7 @@ func (p *plugin) updateCmd() *cobra.Command {
}
if needConfirm {
confirm := false
prompt := &survey.Confirm{
Message: "Do you want to proceed with the update?",
}
survey.AskOne(prompt, &confirm)
confirm := osutils.YesNoPrompt("Do you want to proceed with the update?", false)
if !confirm {
fmt.Println("The command has been cancelled.")
return nil
+154 -78
View File
@@ -13,12 +13,13 @@ import (
"path/filepath"
"reflect"
"slices"
"sort"
"strings"
"time"
"github.com/dop251/goja"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -30,6 +31,7 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
@@ -57,7 +59,7 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
loader.Set(jsName, func(callback string, tags ...string) {
// overwrite the global $app with the hook scoped instance
callback = `function(e) { $app = e.app; return (` + callback + `).call(undefined, e) }`
pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+callback+").apply(undefined, __args)}", true)
tagsAsValues := make([]reflect.Value, len(tags))
for i, tag := range tags {
@@ -81,11 +83,9 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if resErr, ok := res.Export().(error); ok {
return resErr
}
// check for returned Go error value
if resErr := checkGojaValueForError(app, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -101,8 +101,8 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
}
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
loader.Set("cronAdd", func(jobId, cronExpr, handler string) {
pr := goja.MustCompile("", "{("+handler+").apply(undefined)}", true)
cronAdd := func(jobId, cronExpr, handler string) {
pr := goja.MustCompile(defaultScriptPath, "{("+handler+").apply(undefined)}", true)
err := app.Cron().Add(jobId, cronExpr, func() {
err := executors.run(func(executor *goja.Runtime) error {
@@ -121,28 +121,27 @@ func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
if err != nil {
panic("[cronAdd] failed to register cron job " + jobId + ": " + err.Error())
}
})
}
loader.Set("cronAdd", cronAdd)
// note: it is not necessary needed but it is here for consistency
loader.Set("cronRemove", func(jobId string) {
cronRemove := func(jobId string) {
app.Cron().Remove(jobId)
})
}
loader.Set("cronRemove", cronRemove)
// register the removal helper also in the executors to allow removing cron jobs from everywhere
oldFactory := executors.factory
executors.factory = func() *goja.Runtime {
vm := oldFactory()
vm.Set("cronRemove", func(jobId string) {
app.Cron().Remove(jobId)
})
vm.Set("cronAdd", cronAdd)
vm.Set("cronRemove", cronRemove)
return vm
}
for _, item := range executors.items {
item.vm.Set("cronRemove", func(jobId string) {
app.Cron().Remove(jobId)
})
item.vm.Set("cronAdd", cronAdd)
item.vm.Set("cronRemove", cronRemove)
}
}
@@ -188,7 +187,7 @@ func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.Request
// "native" handler func - no need to wrap
return h, nil
case func(goja.FunctionCall) goja.Value, string:
pr := goja.MustCompile("", "{("+handler.String()+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+handler.String()+").apply(undefined, __args)}", true)
wrappedHandler := func(e *core.RequestEvent) error {
return executors.run(func(executor *goja.Runtime) error {
@@ -197,11 +196,9 @@ func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.Request
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if v, ok := res.Export().(error); ok {
return v
}
// check for returned Go error value
if resErr := checkGojaValueForError(e.App, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -242,7 +239,7 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
return nil, errors.New("missing or invalid Middleware function")
}
pr := goja.MustCompile("", "{("+v.serializedFunc+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+v.serializedFunc+").apply(undefined, __args)}", true)
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
Id: v.id,
@@ -254,11 +251,9 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if v, ok := res.Export().(error); ok {
return v
}
// check for returned Go error value
if resErr := checkGojaValueForError(e.App, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -266,7 +261,7 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
},
}
case func(goja.FunctionCall) goja.Value, string:
pr := goja.MustCompile("", "{("+m.String()+").apply(undefined, __args)}", true)
pr := goja.MustCompile(defaultScriptPath, "{("+m.String()+").apply(undefined, __args)}", true)
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
Func: func(e *core.RequestEvent) error {
@@ -276,11 +271,9 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// (legacy) check for returned Go error value
if res != nil {
if v, ok := res.Export().(error); ok {
return v
}
// check for returned Go error value
if resErr := checkGojaValueForError(e.App, res); resErr != nil {
return resErr
}
return normalizeException(err)
@@ -295,6 +288,8 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
return wrappedMiddlewares, nil
}
var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](nil)
func baseBinds(vm *goja.Runtime) {
vm.SetFieldNameMapper(FieldMapper{})
@@ -348,10 +343,11 @@ func baseBinds(vm *goja.Runtime) {
vm.Set("arrayOf", func(model any) any {
mt := reflect.TypeOf(model)
st := reflect.SliceOf(mt)
elem := reflect.New(st).Elem()
st := cachedArrayOfTypes.GetOrSet(mt, func() reflect.Type {
return reflect.SliceOf(mt)
})
return elem.Addr().Interface()
return reflect.New(st).Elem().Addr().Interface()
})
vm.Set("unmarshal", func(data, dst any) error {
@@ -534,6 +530,21 @@ func baseBinds(vm *goja.Runtime) {
return instanceValue
})
// note: named Timezone to avoid conflicts with the JS Location interface.
vm.Set("Timezone", func(call goja.ConstructorCall) *goja.Object {
name, _ := call.Argument(0).Export().(string)
instance, err := time.LoadLocation(name)
if err != nil {
instance = time.UTC
}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
vm.Set("DateTime", func(call goja.ConstructorCall) *goja.Object {
instance := types.NowDateTime()
@@ -696,6 +707,7 @@ func osBinds(vm *goja.Runtime) {
obj.Set("exit", os.Exit)
obj.Set("getenv", os.Getenv)
obj.Set("dirFS", os.DirFS)
obj.Set("stat", os.Stat)
obj.Set("readFile", os.ReadFile)
obj.Set("writeFile", os.WriteFile)
obj.Set("readDir", os.ReadDir)
@@ -899,12 +911,65 @@ func httpClientBinds(vm *goja.Runtime) {
// -------------------------------------------------------------------
// checkGojaValueForError resolves the provided goja.Value and tries
// to extract its underlying error value (if any).
func checkGojaValueForError(app core.App, value goja.Value) error {
if value == nil {
return nil
}
exported := value.Export()
switch v := exported.(type) {
case error:
return v
case *goja.Promise:
// Promise as return result is not officially supported but try to
// resolve any thrown exception to avoid silently ignoring it
app.Logger().Warn("the handler must a non-async function and not return a Promise")
if promiseErr, ok := v.Result().Export().(error); ok {
return normalizeException(promiseErr)
}
}
return nil
}
// normalizeException checks if the provided error is a goja.Exception
// and attempts to return its underlying Go error.
//
// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
func normalizeException(err error) error {
if err == nil {
return nil
}
jsException, ok := err.(*goja.Exception)
if !ok {
return err // no exception
}
switch v := jsException.Value().Export().(type) {
case error:
err = v
case map[string]any: // goja.GoError
if vErr, ok := v["value"].(error); ok {
err = vErr
}
}
return err
}
var cachedFactoryFuncTypes = store.New[string, reflect.Type](nil)
// registerFactoryAsConstructor registers the factory function as native JS constructor.
//
// If there is missing or nil arguments, their type zero value is used.
func registerFactoryAsConstructor(vm *goja.Runtime, constructorName string, factoryFunc any) {
rv := reflect.ValueOf(factoryFunc)
rt := reflect.TypeOf(factoryFunc)
rt := cachedFactoryFuncTypes.GetOrSet(constructorName, func() reflect.Type {
return reflect.TypeOf(factoryFunc)
})
totalArgs := rt.NumIn()
vm.Set(constructorName, func(call goja.ConstructorCall) *goja.Object {
@@ -970,9 +1035,19 @@ func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, ins
return instanceValue
}
var cachedDynamicModelStructs = store.New[string, reflect.Type](nil)
// newDynamicModel creates a new dynamic struct with fields based
// on the specified "shape".
//
// The "shape" values are used as defaults and could be of type:
// - int (ex. 0)
// - float (ex. -0)
// - string (ex. "")
// - bool (ex. false)
// - slice (ex. [])
// - map (ex. map[string]any{})
//
// Example:
//
// m := newDynamicModel(map[string]any{
@@ -980,13 +1055,21 @@ func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, ins
// "total": 0,
// })
func newDynamicModel(shape map[string]any) any {
shapeValues := make([]reflect.Value, 0, len(shape))
structFields := make([]reflect.StructField, 0, len(shape))
info := make([]*shapeFieldInfo, 0, len(shape))
for k, v := range shape {
var hash strings.Builder
sortedKeys := make([]string, 0, len(shape))
for k := range shape {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
for _, k := range sortedKeys {
v := shape[k]
vt := reflect.TypeOf(v)
switch kind := vt.Kind(); kind {
switch vt.Kind() {
case reflect.Map:
raw, _ := json.Marshal(v)
newV := types.JSONMap[any]{}
@@ -1001,47 +1084,40 @@ func newDynamicModel(shape map[string]any) any {
vt = reflect.TypeOf(newV)
}
shapeValues = append(shapeValues, reflect.ValueOf(v))
hash.WriteString(k)
hash.WriteString(":")
hash.WriteString(vt.String()) // it doesn't guarantee to be unique across all types but it should be fine with the primitive types DynamicModel is used
hash.WriteString("|")
structFields = append(structFields, reflect.StructField{
Name: inflector.UcFirst(k), // ensures that the field is exportable
Type: vt,
Tag: reflect.StructTag(`db:"` + k + `" json:"` + k + `" form:"` + k + `"`),
})
info = append(info, &shapeFieldInfo{key: k, value: v, valueType: vt})
}
st := reflect.StructOf(structFields)
st := cachedDynamicModelStructs.GetOrSet(hash.String(), func() reflect.Type {
structFields := make([]reflect.StructField, len(info))
for i, item := range info {
structFields[i] = reflect.StructField{
Name: inflector.UcFirst(item.key), // ensures that the field is exportable
Type: item.valueType,
Tag: reflect.StructTag(`db:"` + item.key + `" json:"` + item.key + `" form:"` + item.key + `"`),
}
}
return reflect.StructOf(structFields)
})
elem := reflect.New(st).Elem()
for i, v := range shapeValues {
elem.Field(i).Set(v)
// load default values into the new model
for i, item := range info {
elem.Field(i).Set(reflect.ValueOf(item.value))
}
return elem.Addr().Interface()
}
// normalizeException checks if the provided error is a goja.Exception
// and attempts to return its underlying Go error.
//
// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
func normalizeException(err error) error {
if err == nil {
return nil
}
jsException, ok := err.(*goja.Exception)
if !ok {
return err // no exception
}
switch v := jsException.Value().Export().(type) {
case error:
err = v
case map[string]any: // goja.GoError
if vErr, ok := v["value"].(error); ok {
err = vErr
}
}
return err
type shapeFieldInfo struct {
value any
valueType reflect.Type
key string
}
+132 -18
View File
@@ -44,7 +44,7 @@ func TestBaseBindsCount(t *testing.T) {
vm := goja.New()
baseBinds(vm)
testBindsCount(vm, "this", 32, t)
testBindsCount(vm, "this", 33, t)
}
func TestBaseBindsSleep(t *testing.T) {
@@ -83,7 +83,7 @@ func TestBaseBindsReaderToString(t *testing.T) {
}
}
func TestBaseBindsToString(t *testing.T) {
func TestBaseBindsToStringAndToBytes(t *testing.T) {
vm := goja.New()
baseBinds(vm)
vm.Set("scenarios", []struct {
@@ -464,7 +464,7 @@ func TestBaseBindsMailerMessage(t *testing.T) {
t.Fatal(err)
}
expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":null}`
expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":null,"inlineAttachments":null}`
if string(raw) != expected {
t.Fatalf("Expected \n%s, \ngot \n%s", expected, raw)
@@ -538,6 +538,31 @@ func TestBaseBindsMiddleware(t *testing.T) {
}
}
func TestBaseBindsTimezone(t *testing.T) {
vm := goja.New()
baseBinds(vm)
_, err := vm.RunString(`
const v0 = (new Timezone()).string();
if (v0 != "UTC") {
throw new Error("(v0) Expected UTC got " + v0)
}
const v1 = (new Timezone("invalid")).string();
if (v1 != "UTC") {
throw new Error("(v1) Expected UTC got " + v1)
}
const v2 = (new Timezone("EET")).string();
if (v2 != "EET") {
throw new Error("(v2) Expected EET got " + v2)
}
`)
if err != nil {
t.Fatal(err)
}
}
func TestBaseBindsDateTime(t *testing.T) {
vm := goja.New()
baseBinds(vm)
@@ -1112,6 +1137,65 @@ func TestLoadingDynamicModel(t *testing.T) {
}
}
func TestDynamicModelMapFieldCaching(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
dbxBinds(vm)
vm.Set("$app", app)
_, err := vm.RunString(`
let m1 = new DynamicModel({
int: 0,
float: -0,
text: "",
bool: false,
obj: {},
arr: [],
})
let m2 = new DynamicModel({
int: 0,
float: -0,
text: "",
bool: false,
obj: {},
arr: [],
})
m1.int = 1
m1.float = 1.5
m1.text = "a"
m1.bool = true
m1.obj.set("a", 1)
m1.arr.push(1)
m2.int = 2
m2.float = 2.5
m2.text = "b"
m2.bool = false
m2.obj.set("b", 1)
m2.arr.push(2)
let m1Expected = '{"arr":[1],"bool":true,"float":1.5,"int":1,"obj":{"a":1},"text":"a"}';
let m1Serialized = JSON.stringify(m1);
if (m1Serialized != m1Expected) {
throw new Error("Expected m1 \n" + m1Expected + "\ngot\n" + m1Serialized);
}
let m2Expected = '{"arr":[2],"bool":false,"float":2.5,"int":2,"obj":{"b":1},"text":"b"}';
let m2Serialized = JSON.stringify(m2);
if (m2Serialized != m2Expected) {
throw new Error("Expected m2 \n" + m2Expected + "\ngot\n" + m2Serialized);
}
`)
if err != nil {
t.Fatal(err)
}
}
func TestLoadingArrayOf(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1354,7 +1438,7 @@ func TestCronBindsCount(t *testing.T) {
testBindsCount(vm, "this", 2, t)
pool.run(func(poolVM *goja.Runtime) error {
testBindsCount(poolVM, "this", 1, t)
testBindsCount(poolVM, "this", 2, t)
return nil
})
}
@@ -1513,13 +1597,14 @@ func TestRouterBinds(t *testing.T) {
defer app.Cleanup()
result := &struct {
AddCount int
WithCount int
RouteMiddlewareCalls int
GlobalMiddlewareCalls int
}{}
vmFactory := func() *goja.Runtime {
vm := goja.New()
baseBinds(vm)
apisBinds(vm)
vm.Set("$app", app)
vm.Set("result", result)
return vm
@@ -1532,14 +1617,20 @@ func TestRouterBinds(t *testing.T) {
_, err := vm.RunString(`
routerAdd("GET", "/test", (e) => {
result.addCount++;
result.routeMiddlewareCalls++;
}, (e) => {
result.addCount++;
result.routeMiddlewareCalls++;
return e.next();
})
// Promise is not technically supported as return result
// but we try to resolve it at least for thrown errors
routerAdd("GET", "/error", async (e) => {
throw new ApiError(456, 'test', null)
})
routerUse((e) => {
result.withCount++;
result.globalMiddlewareCalls++;
return e.next();
})
@@ -1560,21 +1651,44 @@ func TestRouterBinds(t *testing.T) {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
mux, err := serveEvent.Router.BuildMux()
if err != nil {
t.Fatalf("Failed to build router mux: %v", err)
}
mux.ServeHTTP(rec, req)
if result.AddCount != 2 {
t.Fatalf("Expected AddCount %d, got %d", 2, result.AddCount)
scenarios := []struct {
method string
path string
expectedRouteMiddlewareCalls int
expectedGlobalMiddlewareCalls int
expectedCode int
}{
{"GET", "/test", 2, 1, 200},
{"GET", "/error", 0, 1, 456},
}
if result.WithCount != 1 {
t.Fatalf("Expected WithCount %d, got %d", 1, result.WithCount)
for _, s := range scenarios {
t.Run(s.method+" "+s.path, func(t *testing.T) {
// reset
result.RouteMiddlewareCalls = 0
result.GlobalMiddlewareCalls = 0
rec := httptest.NewRecorder()
req := httptest.NewRequest(s.method, s.path, nil)
mux.ServeHTTP(rec, req)
if result.RouteMiddlewareCalls != s.expectedRouteMiddlewareCalls {
t.Fatalf("Expected RouteMiddlewareCalls %d, got %d", s.expectedRouteMiddlewareCalls, result.RouteMiddlewareCalls)
}
if result.GlobalMiddlewareCalls != s.expectedGlobalMiddlewareCalls {
t.Fatalf("Expected GlobalMiddlewareCalls %d, got %d", s.expectedGlobalMiddlewareCalls, result.GlobalMiddlewareCalls)
}
if rec.Code != s.expectedCode {
t.Fatalf("Expected status code %d, got %d", s.expectedCode, rec.Code)
}
})
}
}
@@ -1589,5 +1703,5 @@ func TestOsBindsCount(t *testing.T) {
vm := goja.New()
osBinds(vm)
testBindsCount(vm, "$os", 17, t)
testBindsCount(vm, "$os", 18, t)
}
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -579,6 +579,32 @@ declare class Middleware {
)
}
interface Timezone extends time.Location{} // merge
/**
* Timezone returns the timezone location with the given name.
*
* The name is expected to be a location name corresponding to a file
* in the IANA Time Zone database, such as "America/New_York".
*
* If the name is "Local", LoadLocation returns Local.
*
* If the name is "", invalid or "UTC", returns UTC.
*
* The constructor is equivalent to calling the Go ` + "`" + `time.LoadLocation(name)` + "`" + ` method.
*
* Example:
*
* ` + "```" + `js
* const zone = new Timezone("America/New_York")
* $app.cron().setTimezone(zone)
* ` + "```" + `
*
* @group PocketBase
*/
declare class Timezone implements time.Location {
constructor(name?: string)
}
interface DateTime extends types.DateTime{} // merge
/**
* DateTime defines a single DateTime type instance.
@@ -856,6 +882,7 @@ declare namespace $os {
export let dirFS: os.dirFS
export let readFile: os.readFile
export let writeFile: os.writeFile
export let stat: os.stat
export let readDir: os.readDir
export let tempDir: os.tempDir
export let truncate: os.truncate
@@ -1131,7 +1158,6 @@ func main() {
"pflag.*": "any",
"flag.*": "any",
"log.*": "any",
"aws.*": "any",
"http.Client": "any",
"mail.Address": "{ address: string; name?: string; }", // prevents the LSP to complain in case no name is provided
},
+21 -3
View File
@@ -35,6 +35,24 @@ import (
const typesFileName = "types.d.ts"
var defaultScriptPath = "pb.js"
func init() {
// For backward compatibility and consistency with the Go exposed
// methods that operate with relative paths (e.g. `$os.writeFile`),
// we define the "current JS module" as if it is a file in the current working directory
// (the filename itself doesn't really matter and in our case the hook handlers are executed as separate "programs").
//
// This is necessary for `require(module)` to properly traverse parents node_modules (goja_nodejs#95).
cwd, err := os.Getwd()
if err != nil {
// truly rare case, log just for debug purposes
color.Yellow("Failed to retrieve the current working directory: %v", err)
} else {
defaultScriptPath = filepath.Join(cwd, defaultScriptPath)
}
}
// Config defines the config options of the jsvm plugin.
type Config struct {
// OnInit is an optional function that will be called
@@ -191,7 +209,7 @@ func (p *plugin) registerMigrations() error {
p.config.OnInit(vm)
}
_, err := vm.RunString(string(content))
_, err := vm.RunScript(defaultScriptPath, string(content))
if err != nil {
return fmt.Errorf("failed to run migration %s: %w", file, err)
}
@@ -306,7 +324,7 @@ func (p *plugin) registerHooks() error {
}
}()
_, err := loader.RunString(string(content))
_, err := loader.RunScript(defaultScriptPath, string(content))
if err != nil {
panic(err)
}
@@ -389,7 +407,7 @@ func (p *plugin) watchHooks() error {
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
// app restart is currently not supported on Windows
if runtime.GOOS == "windows" {
color.Yellow("File %s changed, please restart the app", event.Name)
color.Yellow("File %s changed, please restart the app manually", event.Name)
} else {
color.Yellow("File %s changed, restarting...", event.Name)
if err := p.app.Restart(); err != nil {
+2 -6
View File
@@ -23,9 +23,9 @@ import (
"path/filepath"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cobra"
)
@@ -156,11 +156,7 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv
resultFilePath := path.Join(dir, filename)
if interactive {
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
confirm := osutils.YesNoPrompt(fmt.Sprintf("Do you really want to create migration %q?", resultFilePath), false)
if !confirm {
fmt.Println("The command has been cancelled")
return "", nil
+20
View File
@@ -12,7 +12,9 @@ import (
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/cmd"
"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/spf13/cobra"
_ "github.com/pocketbase/pocketbase/migrations"
@@ -137,6 +139,24 @@ func NewWithConfig(config Config) *PocketBase {
// hide the default help command (allow only `--help` flag)
pb.RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
// https://github.com/pocketbase/pocketbase/issues/6136
pb.OnBootstrap().Bind(&hook.Handler[*core.BootstrapEvent]{
Id: ModerncDepsCheckHookId,
Func: func(be *core.BootstrapEvent) error {
if err := be.Next(); err != nil {
return err
}
// run separately to avoid blocking
app := be.App
routine.FireAndForget(func() {
checkModerncDeps(app)
})
return nil
},
})
return pb
}
+28 -12
View File
@@ -70,27 +70,43 @@ func (t *TestApp) registerEventCall(name string) {
// NewTestApp creates and initializes a test application instance.
//
// It is the caller's responsibility to call `app.Cleanup()`
// when the app is no longer needed.
// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed.
func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
var testDataDir string
if len(optTestDataDir) == 0 || optTestDataDir[0] == "" {
// fallback to the default test data directory
_, currentFile, _, _ := runtime.Caller(0)
testDataDir = filepath.Join(path.Dir(currentFile), "data")
} else {
if len(optTestDataDir) > 0 {
testDataDir = optTestDataDir[0]
}
tempDir, err := TempDirClone(testDataDir)
return NewTestAppWithConfig(core.BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
})
}
// NewTestAppWithConfig creates and initializes a test application instance
// from the provided config.
//
// If config.DataDir is not set it fallbacks to the default internal test data directory.
//
// config.DataDir is cloned for each new test application instance.
//
// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed.
func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) {
if config.DataDir == "" {
// fallback to the default test data directory
_, currentFile, _, _ := runtime.Caller(0)
config.DataDir = filepath.Join(path.Dir(currentFile), "data")
}
tempDir, err := TempDirClone(config.DataDir)
if err != nil {
return nil, err
}
app := core.NewBaseApp(core.BaseAppConfig{
DataDir: tempDir,
EncryptionEnv: "pb_test_env",
})
// replace with the clone
config.DataDir = tempDir
app := core.NewBaseApp(config)
// load data dir and db connections
if err := app.Bootstrap(); err != nil {
+9 -10
View File
@@ -6,7 +6,7 @@ import (
"errors"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/oauth2"
@@ -138,19 +138,18 @@ func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
// validate common claims per https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user#3383769
// ---
err = claims.Valid() // exp, iat, etc.
jwtValidator := jwt.NewValidator(
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
jwt.WithLeeway(idTokenLeeway),
jwt.WithIssuer("https://appleid.apple.com"),
jwt.WithAudience(p.clientId),
)
err = jwtValidator.Validate(claims)
if err != nil {
return nil, err
}
if !claims.VerifyIssuer("https://appleid.apple.com", true) {
return nil, errors.New("iss must be https://appleid.apple.com")
}
if !claims.VerifyAudience(p.clientId, true) {
return nil, errors.New("aud must be the developer's client_id")
}
// validate id_token signature
//
// note: this step could be technically considered optional because we trust
+10 -1
View File
@@ -7,7 +7,7 @@ import (
)
func TestProvidersCount(t *testing.T) {
expected := 29
expected := 30
if total := len(auth.Providers); total != expected {
t.Fatalf("Expected %d providers, got %d", expected, total)
@@ -287,4 +287,13 @@ func TestNewProviderByName(t *testing.T) {
if _, ok := p.(*auth.Linear); !ok {
t.Error("Expected to be instance of *auth.Linear")
}
// trakt
p, err = auth.NewProviderByName(auth.NameTrakt)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Trakt); !ok {
t.Error("Expected to be instance of *auth.Trakt")
}
}
+2 -2
View File
@@ -51,18 +51,18 @@ func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Id int `json:"id"`
Name string `json:"full_name"`
Username string `json:"login"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Id),
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
+2 -2
View File
@@ -54,17 +54,17 @@ func (p *Gitee) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
extracted := struct {
Login string `json:"login"`
Id int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Id),
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Login,
AvatarURL: extracted.AvatarURL,
+2 -2
View File
@@ -54,17 +54,17 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
extracted := struct {
Login string `json:"login"`
Id int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Id),
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Login,
Email: extracted.Email,
+2 -2
View File
@@ -51,18 +51,18 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Id int `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Id),
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
+7 -7
View File
@@ -32,9 +32,9 @@ func NewGoogleProvider() *Google {
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
},
authURL: "https://accounts.google.com/o/oauth2/auth",
tokenURL: "https://accounts.google.com/o/oauth2/token",
userInfoURL: "https://www.googleapis.com/oauth2/v1/userinfo",
authURL: "https://accounts.google.com/o/oauth2/v2/auth",
tokenURL: "https://oauth2.googleapis.com/token",
userInfoURL: "https://www.googleapis.com/oauth2/v3/userinfo",
}}
}
@@ -51,11 +51,11 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Id string `json:"id"`
Id string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Picture string `json:"picture"`
VerifiedEmail bool `json:"verified_email"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
@@ -72,7 +72,7 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.VerifiedEmail {
if extracted.EmailVerified {
user.Email = extracted.Email
}
+2 -2
View File
@@ -52,7 +52,6 @@ func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Id int `json:"id"`
Profile struct {
Nickname string `json:"nickname"`
ImageURL string `json:"profile_image"`
@@ -62,13 +61,14 @@ func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
IsEmailVerified bool `json:"is_email_verified"`
IsEmailValid bool `json:"is_email_valid"`
} `json:"kakao_account"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Id),
Id: strconv.FormatInt(extracted.Id, 10),
Username: extracted.Profile.Nickname,
AvatarURL: extracted.Profile.ImageURL,
RawUser: rawUser,
+27 -9
View File
@@ -10,18 +10,35 @@ import (
"io"
"math/big"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/oauth2"
)
// idTokenLeeway is the optional leeway for the id_token timestamp fields validation.
//
// It can be changed externally using the PB_ID_TOKEN_LEEWAY env variable
// (the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute).
var idTokenLeeway time.Duration = 5 * time.Minute
func init() {
Providers[NameOIDC] = wrapFactory(NewOIDCProvider)
Providers[NameOIDC+"2"] = wrapFactory(NewOIDCProvider)
Providers[NameOIDC+"3"] = wrapFactory(NewOIDCProvider)
if leewayStr := os.Getenv("PB_ID_TOKEN_LEEWAY"); leewayStr != "" {
leeway, err := strconv.Atoi(leewayStr)
if err == nil {
idTokenLeeway = time.Duration(leeway) * time.Second
}
}
}
var _ Provider = (*OIDC)(nil)
@@ -128,24 +145,25 @@ func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
return nil, err
}
// validate common claims like exp, iat, etc.
err = claims.Valid()
// validate common claims
jwtValidator := jwt.NewValidator(
jwt.WithIssuedAt(),
jwt.WithLeeway(idTokenLeeway),
jwt.WithAudience(p.clientId),
)
err = jwtValidator.Validate(claims)
if err != nil {
return nil, err
}
// validate aud
if !claims.VerifyAudience(p.clientId, true) {
return nil, errors.New("aud must be the developer's client_id")
}
// validate iss (if "issuers" extra config is set)
issuers := cast.ToStringSlice(p.Extra()["issuers"])
if len(issuers) > 0 {
var isIssValid bool
claimIssuer, _ := claims.GetIssuer()
for _, issuer := range issuers {
if claims.VerifyIssuer(issuer, true) {
if security.Equal(claimIssuer, issuer) {
isIssValid = true
break
}
+2 -2
View File
@@ -53,7 +53,7 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Id int `json:"id"`
Id int64 `json:"id"`
FirstName string `json:"firstname"`
LastName string `json:"lastname"`
Username string `json:"username"`
@@ -78,7 +78,7 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.Id != 0 {
user.Id = strconv.Itoa(extracted.Id)
user.Id = strconv.FormatInt(extracted.Id, 10)
}
return user, nil
+102
View File
@@ -0,0 +1,102 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameTrakt] = wrapFactory(NewTraktProvider)
}
var _ Provider = (*Trakt)(nil)
// NameTrakt is the unique name of the Trakt provider.
const NameTrakt string = "trakt"
// Trakt allows authentication via Trakt OAuth2.
type Trakt struct {
BaseProvider
}
// NewTraktProvider creates new Trakt provider instance with some defaults.
func NewTraktProvider() *Trakt {
return &Trakt{BaseProvider{
ctx: context.Background(),
displayName: "Trakt",
pkce: true,
authURL: "https://trakt.tv/oauth/authorize",
tokenURL: "https://api.trakt.tv/oauth/token",
userInfoURL: "https://api.trakt.tv/users/settings",
}}
}
// FetchAuthUser returns an AuthUser instance based on Trakt's user settings API.
// API reference: https://trakt.docs.apiary.io/#reference/users/settings/retrieve-settings
func (p *Trakt) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
User struct {
Username string `json:"username"`
Name string `json:"name"`
Ids struct {
Slug string `json:"slug"`
UUID string `json:"uuid"`
} `json:"ids"`
Images struct {
Avatar struct {
Full string `json:"full"`
} `json:"avatar"`
} `json:"images"`
} `json:"user"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.User.Ids.UUID,
Username: extracted.User.Username,
Name: extracted.User.Name,
AvatarURL: extracted.User.Images.Avatar.Full,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
//
// This differ from BaseProvider because Trakt requires a number of
// mandatory headers for all requests
// (https://trakt.docs.apiary.io/#introduction/required-headers).
func (p *Trakt) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-type", "application/json")
req.Header.Set("trakt-api-key", p.clientId)
req.Header.Set("trakt-api-version", "2")
return p.sendRawUserInfoRequest(req, token)
}
+2 -2
View File
@@ -58,7 +58,7 @@ func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
extracted := struct {
Response []struct {
Id int `json:"id"`
Id int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"screen_name"`
@@ -75,7 +75,7 @@ func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
user := &AuthUser{
Id: strconv.Itoa(extracted.Response[0].Id),
Id: strconv.FormatInt(extracted.Response[0].Id, 10),
Name: strings.TrimSpace(extracted.Response[0].FirstName + " " + extracted.Response[0].LastName),
Username: extracted.Response[0].Username,
AvatarURL: extracted.Response[0].AvatarURL,
+41 -19
View File
@@ -11,22 +11,18 @@ package cron
import (
"errors"
"fmt"
"slices"
"sync"
"time"
)
type job struct {
schedule *Schedule
run func()
}
// Cron is a crontab-like struct for tasks/jobs scheduling.
type Cron struct {
timezone *time.Location
ticker *time.Ticker
startTimer *time.Timer
jobs map[string]*job
tickerDone chan bool
jobs []*Job
interval time.Duration
mux sync.RWMutex
}
@@ -40,7 +36,7 @@ func New() *Cron {
return &Cron{
interval: 1 * time.Minute,
timezone: time.UTC,
jobs: map[string]*job{},
jobs: []*Job{},
tickerDone: make(chan bool),
}
}
@@ -82,23 +78,30 @@ func (c *Cron) MustAdd(jobId string, cronExpr string, run func()) {
//
// cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour).
// Check cron.NewSchedule() for the supported tokens.
func (c *Cron) Add(jobId string, cronExpr string, run func()) error {
if run == nil {
return errors.New("failed to add new cron job: run must be non-nil function")
func (c *Cron) Add(jobId string, cronExpr string, fn func()) error {
if fn == nil {
return errors.New("failed to add new cron job: fn must be non-nil function")
}
c.mux.Lock()
defer c.mux.Unlock()
schedule, err := NewSchedule(cronExpr)
if err != nil {
return fmt.Errorf("failed to add new cron job: %w", err)
}
c.jobs[jobId] = &job{
c.mux.Lock()
defer c.mux.Unlock()
// remove previous (if any)
c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool {
return j.Id() == jobId
})
// add new
c.jobs = append(c.jobs, &Job{
id: jobId,
fn: fn,
schedule: schedule,
run: run,
}
})
return nil
}
@@ -108,7 +111,13 @@ func (c *Cron) Remove(jobId string) {
c.mux.Lock()
defer c.mux.Unlock()
delete(c.jobs, jobId)
if c.jobs == nil {
return // nothing to remove
}
c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool {
return j.Id() == jobId
})
}
// RemoveAll removes all registered cron jobs.
@@ -116,7 +125,7 @@ func (c *Cron) RemoveAll() {
c.mux.Lock()
defer c.mux.Unlock()
c.jobs = map[string]*job{}
c.jobs = []*Job{}
}
// Total returns the current total number of registered cron jobs.
@@ -127,6 +136,19 @@ func (c *Cron) Total() int {
return len(c.jobs)
}
// Jobs returns a shallow copy of the currently registered cron jobs.
func (c *Cron) Jobs() []*Job {
c.mux.RLock()
defer c.mux.RUnlock()
copy := make([]*Job, len(c.jobs))
for i, j := range c.jobs {
copy[i] = j
}
return copy
}
// Stop stops the current cron ticker (if not already).
//
// You can resume the ticker by calling Start().
@@ -200,7 +222,7 @@ func (c *Cron) runDue(t time.Time) {
for _, j := range c.jobs {
if j.schedule.IsDue(moment) {
go j.run()
go j.Run()
}
}
}
+45 -3
View File
@@ -2,6 +2,7 @@ package cron
import (
"encoding/json"
"slices"
"testing"
"time"
)
@@ -98,6 +99,11 @@ func TestCronAddAndRemove(t *testing.T) {
// try to remove non-existing (should be no-op)
c.Remove("missing")
indexedJobs := make(map[string]*Job, len(c.jobs))
for _, j := range c.jobs {
indexedJobs[j.Id()] = j
}
// check job keys
{
expectedKeys := []string{"test3", "test2", "test5"}
@@ -107,7 +113,7 @@ func TestCronAddAndRemove(t *testing.T) {
}
for _, k := range expectedKeys {
if c.jobs[k] == nil {
if indexedJobs[k] == nil {
t.Fatalf("Expected job with key %s, got nil", k)
}
}
@@ -121,7 +127,7 @@ func TestCronAddAndRemove(t *testing.T) {
"test5": `{"minutes":{"1":{}},"hours":{"2":{}},"days":{"3":{}},"months":{"4":{}},"daysOfWeek":{"5":{}}}`,
}
for k, v := range expectedSchedules {
raw, err := json.Marshal(c.jobs[k].schedule)
raw, err := json.Marshal(indexedJobs[k].schedule)
if err != nil {
t.Fatal(err)
}
@@ -148,7 +154,7 @@ func TestCronMustAdd(t *testing.T) {
c.MustAdd("test2", "* * * * *", func() {})
if _, ok := c.jobs["test2"]; !ok {
if !slices.ContainsFunc(c.jobs, func(j *Job) bool { return j.Id() == "test2" }) {
t.Fatal("Couldn't find job test2")
}
}
@@ -208,6 +214,42 @@ func TestCronTotal(t *testing.T) {
}
}
func TestCronJobs(t *testing.T) {
t.Parallel()
c := New()
calls := ""
if err := c.Add("a", "1 * * * *", func() { calls += "a" }); err != nil {
t.Fatal(err)
}
if err := c.Add("b", "2 * * * *", func() { calls += "b" }); err != nil {
t.Fatal(err)
}
// overwrite
if err := c.Add("b", "3 * * * *", func() { calls += "b" }); err != nil {
t.Fatal(err)
}
jobs := c.Jobs()
if len(jobs) != 2 {
t.Fatalf("Expected 2 jobs, got %v", len(jobs))
}
for _, j := range jobs {
j.Run()
}
expectedCalls := "ab"
if calls != expectedCalls {
t.Fatalf("Expected %q calls, got %q", expectedCalls, calls)
}
}
func TestCronStartStop(t *testing.T) {
t.Parallel()

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