Compare commits

...
172 Commits
Author SHA1 Message Date
Gani Georgiev 0113fecca9 bumped app version 2025-05-11 00:13:56 +03:00
Gani Georgiev fbc378067d updated error comment 2025-05-04 20:56:40 +03:00
Gani Georgiev e80d64414b [#6792] added filesystem.System.GetReuploadableFile method 2025-05-03 18:37:07 +03:00
Gani Georgiev 7ffe9f63a5 changed the default json field max size to 1mb 2025-05-02 11:49:47 +03:00
Gani Georgiev 5dbd9821e8 soft-deprecated and replaced GetFile with GetReader 2025-05-02 11:27:32 +03:00
Gani Georgiev 87c6c5b483 fixed dev sql log replacements 2025-05-02 11:12:33 +03:00
Gani Georgiev 836fc77ddc [#6689] updated to automatically routes raw write SQL statements to the nonconcurrent db pool 2025-05-02 10:27:41 +03:00
Gani Georgiev 3ef752c232 merge with master 2025-04-28 16:51:18 +03:00
Gani Georgiev eb8dd80859 bumped app version 2025-04-28 14:51:11 +03:00
Gani Georgiev d97b5b1f6c [#6739] use rowid as count column for non-view collections to minimize the need of having the id field as covering index 2025-04-28 14:47:22 +03:00
Gani Georgiev 3885c93d59 [#6780] added temp semaphore to limit the number of goroutines when cleaning files 2025-04-28 14:47:11 +03:00
Gani Georgiev 5713cf422b [#6778] updated the excerpt modifier to properly account for multibyte characters 2025-04-28 12:47:13 +03:00
Gani Georgiev c7c590bace [#6778] updated the excerpt modifier to properly account for multibyte characters 2025-04-28 06:03:11 +03:00
Gani Georgiev fac0d5b899 use rowid as collections order field 2025-04-27 22:08:31 +03:00
Gani Georgiev 902e07e724 updated changelog and jsvm types 2025-04-27 16:33:24 +03:00
Gani Georgiev dc350f0a3e delay default response body write for *Request hooks wrapped in a transaction 2025-04-27 16:25:51 +03:00
Gani Georgiev 1a3efe96ac [#6739] use rowid as count column for non-view collections to minimize the need of having the id field as covering index 2025-04-21 20:55:09 +03:00
Gani Georgiev 18f152a0e7 Merge branch 'master' into develop 2025-04-21 16:34:33 +03:00
Gani Georgiev 9bee3bd0fd added API preview clarification for the geoPoint expected object format 2025-04-20 14:01:07 +03:00
Gani Georgiev b31a0ddcd3 bumped app version 2025-04-20 13:55:13 +03:00
Gani Georgiev 52dcb4192c Merge branch 'master' into develop 2025-04-20 13:41:44 +03:00
Gani Georgiev 2bffa0e2bb updated filesystem.CreateThumb tests 2025-04-20 13:40:24 +03:00
Gani Georgiev 8c0ef15ec2 Merge branch 'master' of github.com:pocketbase/pocketbase 2025-04-20 13:38:59 +03:00
Gani Georgiev 08b1b49e17 Merge branch 'master' of github.com:pocketbase/pocketbase into develop 2025-04-20 13:38:30 +03:00
Kev 🐶 5d46fb054e [#6744] added partial webp tumbs support 2025-04-20 13:36:45 +03:00
Gani Georgiev f9b2842deb updated changelog 2025-04-20 10:47:36 +03:00
Gani Georgiev 0426722e99 added JSVM GeoPointField constructor 2025-04-16 09:12:44 +03:00
Gani Georgiev 33abc0a802 updated geoPoint API preview response 2025-04-16 08:14:56 +03:00
Gani Georgiev 51cbe437e5 updated geoPoint nonempty tooltip info 2025-04-16 08:08:53 +03:00
Gani Georgiev a2b89d7344 updated changelog 2025-04-16 07:41:00 +03:00
Gani Georgiev 6988bf0d7c updated leaflet search autocomplete z-index 2025-04-15 22:23:55 +03:00
Gani Georgiev 02c975467c regenerated jsvm types 2025-04-15 21:58:03 +03:00
Gani Georgiev c7ebd68a92 updated changelog 2025-04-15 21:56:22 +03:00
Gani Georgiev 0876413d87 added types.GeoPoint.AsMap method 2025-04-15 21:43:08 +03:00
Gani Georgiev c0fe600d14 updated npm deps and bumped app version 2025-04-15 21:42:46 +03:00
Gani Georgiev aec4870acd reversed the default geoPoint column props for consistency with the types.GeoPoint marshalization 2025-04-15 21:08:38 +03:00
Gani Georgiev bf9a7b1e3d updated changelog and exposed search.TokenFunctions 2025-04-15 18:03:59 +03:00
Gani Georgiev 46186f84f0 [#6718] fixed collections import error response 2025-04-14 09:29:25 +03:00
Gani Georgiev 4cc797071b updated dependencies and golangci-lint to v2 2025-04-12 20:05:21 +03:00
Gani Georgiev d9af1475ef fixed NewBackupsFilesystem code comment 2025-04-10 09:00:32 +03:00
Gani Georgiev 3e3c316da1 updated changelog 2025-04-08 14:34:40 +03:00
Gani Georgiev 0efbbb0d10 [#6688] added optional timezone identifier argument to the JSVM DateTime constructor 2025-04-08 14:30:50 +03:00
Willow (GHOST) 5d32d22ff5 [#6690] updated patreon to use go oauth2/endpoints 2025-04-08 05:33:52 +03:00
Gani Georgiev e81d85ae7c fixed typo 2025-04-07 16:34:54 +03:00
Gani Georgiev b930de6ff5 added geoDistance calculation test 2025-04-07 12:41:13 +03:00
Gani Georgiev 628efb7a7f fixed geoDistance code comment 2025-04-07 11:58:09 +03:00
Gani Georgiev 201e2ccb05 [#6607] allowed manually changing the password from the create/update hooks 2025-04-05 13:36:31 +03:00
Gani Georgiev e49025c8e5 moved the Create and Manage API rule checks out of the OnRecordCreateRequest hook finalizer 2025-04-04 22:53:14 +03:00
Gani Georgiev 9f6010d38d [#6282] soft-deprecated $http.send result.raw field in favour of result.body 2025-04-04 15:23:34 +03:00
Gani Georgiev 409bcdaa96 [#6597] forced text/javascript content-type when serving .js/.mjs collection uploaded files 2025-04-03 16:17:16 +03:00
Gani Georgiev 2554192c06 added geoDistance docs and tests 2025-04-03 15:55:47 +03:00
Gani Georgiev d135b1e686 added more geoPoint field acccess tests 2025-04-02 23:25:42 +03:00
Gani Georgiev 4c5abd5bd9 added new geoPoint field 2025-04-02 11:52:50 +03:00
Gani Georgiev f3a836eb7c synced with master 2025-03-29 11:22:57 +02:00
Gani Georgiev fb5070d6ab bumped app version 2025-03-29 11:13:30 +02:00
Gani Georgiev c9f39ba167 synced with master 2025-03-29 10:46:42 +02:00
Gani Georgiev e29655aba9 bumped modernc.org/sqlite 2025-03-29 09:44:55 +02:00
Gani Georgiev 32de8ed04a [#6657] allow OIDC email_verified to be int or boolean string 2025-03-29 09:28:31 +02:00
Gani Georgiev 783a99d7cc synced with master 2025-03-28 21:07:16 +02:00
Gani Georgiev 42a008f828 updated changelog 2025-03-28 19:46:58 +02:00
Gani Georgiev d0d5f3d005 bumped app version 2025-03-28 19:32:07 +02:00
Gani Georgiev b5be7f2d3c [#6654] fixed S3 canonical uri parts escaping 2025-03-28 19:28:04 +02:00
Gani Georgiev 334a81c837 Merge branch 'master' into develop 2025-03-26 08:02:01 +02:00
Gani Georgiev d68786df9c updated changelog 2025-03-26 08:01:44 +02:00
Gani Georgiev 70f0e7e141 synced with master 2025-03-25 23:00:56 +02:00
Gani Georgiev 75bba1d0f9 updated modernc.org/sqlite 2025-03-25 22:16:32 +02:00
Gani Georgiev 81cc276ab9 fixed logs clipboard data copy 2025-03-25 22:12:43 +02:00
Gani Georgiev ffc848e92d updated ui/dist 2025-03-25 20:00:47 +02:00
Gani Georgiev 778869c318 [#6639] fixed RecordErrorEvent.Error and CollectionErrorEvent.Error sync with ModelErrorEvent.Error 2025-03-25 19:57:57 +02:00
Gani Georgiev 8e7e0aa5ff fixed logs clipboard data copy 2025-03-24 19:36:43 +02:00
Gani Georgiev e718b88391 synced with master 2025-03-24 18:26:25 +02:00
Gani Georgiev 865408a9b4 updated changelogs 2025-03-24 06:31:58 +02:00
Gani Georgiev 7407bdcaf5 [#6631] fixed and normalized logs error serialization 2025-03-23 20:48:05 +02:00
Gani Georgiev 953feb47fd synced with master 2025-03-22 06:35:26 +02:00
Gani Georgiev 16539335be updated golang-jwt 2025-03-22 06:08:31 +02:00
Gani Georgiev 60a4e3f4d2 updated default select field labels 2025-03-20 14:30:00 +02:00
Gani Georgiev d61ec398a2 [#4674] updated select field ui 2025-03-20 13:46:40 +02:00
Gani Georgiev 9f1946057f use gabriel-vasile/mimetype for the mail attachments 2025-03-18 00:52:33 +02:00
Gani Georgiev 824c7db388 updated auth collection create and update examples 2025-03-17 21:19:47 +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 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 023428ce8f synced with master 2025-01-11 12:38:11 +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 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 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 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
281 changed files with 18432 additions and 8962 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '>=1.23.5'
go-version: '>=1.23.9'
# 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.
+256
View File
@@ -1,3 +1,259 @@
## v0.28.0
- Write the default response body of `*Request` hooks that are wrapped in a transaction after the related transaction completes to allow propagating the transaction error ([#6462](https://github.com/pocketbase/pocketbase/discussions/6462#discussioncomment-12207818)).
- Updated `app.DB()` to automatically routes raw write SQL statements to the nonconcurrent db pool ([#6689](https://github.com/pocketbase/pocketbase/discussions/6689)).
_For the rare cases when it is needed users still have the option to explicitly target the specific pool they want using `app.ConcurrentDB()`/`app.NonconcurrentDB()`._
- ⚠️ Changed the default `json` field max size to 1MB.
_Users still have the option to adjust the default limit from the collection field options but keep in mind that storing large strings/blobs in the database is known to cause performance issues and should be avoided when possible._
- ⚠️ Soft-deprecated and replaced `filesystem.System.GetFile(fileKey)` with `filesystem.System.GetReader(fileKey)` to avoid the confusion with `filesystem.File`.
_The old method will still continue to work for at least until v0.29.0 but you'll get a console warning to replace it with `GetReader`._
- Added new `filesystem.System.GetReuploadableFile(fileKey, preserveName)` method to return an existing blob as a `*filesystem.File` value ([#6792](https://github.com/pocketbase/pocketbase/discussions/6792)).
_This method could be useful in case you want to clone an existing Record file and assign it to a new Record (e.g. in a Record duplicate action)._
- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.)
## v0.27.2
- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)).
- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)).
- Use `rowid` as count column for non-view collections to minimize the need of having the id field in a covering index ([#6739](https://github.com/pocketbase/pocketbase/discussions/6739))
## v0.27.1
- Updated example `geoPoint` API preview body data.
- Added JSVM `new GeoPointField({ ... })` constructor.
- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)).
- Updated npm dev dependencies.
## v0.27.0
- ⚠️ Moved the Create and Manage API rule checks out of the `OnRecordCreateRequest` hook finalizer, **aka. now all CRUD API rules are checked BEFORE triggering their corresponding `*Request` hook**.
This was done to minimize the confusion regarding the firing order of the request operations, making it more predictable and consistent with the other record List/View/Update/Delete request actions.
It could be a minor breaking change if you are relying on the old behavior and have a Go `tests.ApiScenario` that is testing a Create API rule failure and expect `OnRecordCreateRequest` to be fired. In that case for example you may have to update your test scenario like:
```go
tests.ApiScenario{
Name: "Example test that checks a Create API rule failure"
Method: http.MethodPost,
URL: "/api/collections/example/records",
...
// old:
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
// new:
ExpectedEvents: map[string]int{"*": 0},
}
```
If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample.
- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates.
In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](htts://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)).
- Updated the mail attachments auto MIME type detection to use `gabriel-vasile/mimetype` for consistency and broader sniffing signatures support.
- Forced `text/javascript` Content-Type when serving `.js`/`.mjs` collection uploaded files with the `/api/files/...` endpoint ([#6597](https://github.com/pocketbase/pocketbase/issues/6597)).
- Added second optional JSVM `DateTime` constructor argument for specifying a default timezone as TZ identifier when parsing the date string as alternative to a fixed offset in order to better handle daylight saving time nuances ([#6688](https://github.com/pocketbase/pocketbase/discussions/6688)):
```js
// the same as with CET offset: new DateTime("2025-10-26 03:00:00 +01:00")
new DateTime("2025-10-26 03:00:00", "Europe/Amsterdam") // 2025-10-26 02:00:00.000Z
// the same as with CEST offset: new DateTime("2025-10-26 01:00:00 +02:00")
new DateTime("2025-10-26 01:00:00", "Europe/Amsterdam") // 2025-10-25 23:00:00.000Z
```
- Soft-deprecated the `$http.send`'s `result.raw` field in favor of `result.body` that contains the response body as plain bytes slice to avoid the discrepancies between Go and the JSVM when casting binary data to string.
- Updated `modernc.org/sqlite` to 1.37.0.
- Other minor improvements (_removed the superuser fields from the auth record create/update body examples, allowed programmatically updating the auth record password from the create/update hooks, fixed collections import error response, etc._).
## v0.26.6
- Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
- Updated `modernc.org/sqlite` to 1.36.3.
## v0.26.5
- Fixed canonical URI parts escaping when generating the S3 request signature ([#6654](https://github.com/pocketbase/pocketbase/issues/6654)).
## v0.26.4
- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)).
- Fixed logs details copy to clipboard action.
- Updated `modernc.org/sqlite` to 1.36.2.
## v0.26.3
- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
## 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)).
+20
View File
@@ -2,6 +2,26 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.34
- (_Backported from v0.26.6_) Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
## v0.22.33
- (_Backported from v0.26.3_) Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
## 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)).
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"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) {
@@ -490,7 +490,7 @@ func TestBackupsDownload(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
+5 -5
View File
@@ -49,7 +49,7 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
params["id"] = id // required for the path value
ir.Method = "PATCH"
ir.URL = "/api/collections/" + params["collection"] + "/records/" + id + params["query"]
return recordUpdate(next)
return recordUpdate(false, next)
}
}
@@ -57,16 +57,16 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
// ---
ir.Method = "POST"
ir.URL = "/api/collections/" + params["collection"] + "/records" + params["query"]
return recordCreate(next)
return recordCreate(false, next)
},
regexp.MustCompile(`^POST /api/collections/(?P<collection>[^\/\?]+)/records(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordCreate(next)
return recordCreate(false, next)
},
regexp.MustCompile(`^PATCH /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordUpdate(next)
return recordUpdate(false, next)
},
regexp.MustCompile(`^DELETE /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordDelete(next)
return recordDelete(false, next)
},
}
+15 -5
View File
@@ -45,7 +45,9 @@ func collectionsList(e *core.RequestEvent) error {
event.Result = result
return event.App.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListRequestEvent) error {
return e.JSON(http.StatusOK, e.Result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Result)
})
})
}
@@ -60,7 +62,9 @@ func collectionView(e *core.RequestEvent) error {
event.Collection = collection
return e.App.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -98,7 +102,9 @@ func collectionCreate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to create collection. Raw error: \n"+err.Error(), nil)
}
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -128,7 +134,9 @@ func collectionUpdate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to update collection. Raw error: \n"+err.Error(), nil)
}
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -159,7 +167,9 @@ func collectionDelete(e *core.RequestEvent) error {
return e.BadRequestError(msg, err)
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
+4 -2
View File
@@ -29,12 +29,14 @@ func collectionsImport(e *core.RequestEvent) error {
return event.App.OnCollectionsImportRequest().Trigger(event, func(e *core.CollectionsImportRequestEvent) error {
importErr := e.App.ImportCollections(e.CollectionsData, form.DeleteMissing)
if importErr == nil {
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
}
// validation failure
var validationErrors validation.Errors
if errors.As(err, &validationErrors) {
if errors.As(importErr, &validationErrors) {
return e.BadRequestError("Failed to import collections.", validationErrors)
}
+112
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -88,6 +89,7 @@ func TestCollectionsImport(t *testing.T) {
`import2`,
`fields`,
},
NotExpectedContent: []string{"Raw error:"},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
@@ -109,6 +111,71 @@ func TestCollectionsImport(t *testing.T) {
}
},
},
{
Name: "authorized as superuser + non-validator failure",
Method: http.MethodPut,
URL: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"validation_collections_import_failure"`,
`Raw error:`,
`custom_error`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
"OnCollectionCreate": 1,
"OnCollectionAfterCreateError": 1,
"OnModelCreate": 1,
"OnModelAfterCreateError": 1,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionCreate().BindFunc(func(e *core.CollectionEvent) error {
return errors.New("custom_error")
})
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
collections := []*core.Collection{}
if err := app.CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
{
Name: "authorized as superuser + successful collections create",
Method: http.MethodPut,
@@ -249,6 +316,51 @@ func TestCollectionsImport(t *testing.T) {
}
},
},
{
Name: "OnCollectionsImportRequest tx body write check",
Method: http.MethodPut,
URL: "/api/collections/import",
Body: strings.NewReader(`{
"deleteMissing": true,
"collections":[
{"name": "test123"},
{
"id":"wsmn24bux7wo113",
"name":"demo1",
"fields":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"required":true
}
],
"indexes": []
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionsImportRequest().BindFunc(func(e *core.CollectionsImportRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnCollectionsImportRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
+94 -22
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"os"
"path/filepath"
@@ -130,6 +129,32 @@ func TestCollectionsList(t *testing.T) {
"OnCollectionsListRequest": 1,
},
},
{
Name: "OnCollectionsListRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionsListRequest().BindFunc(func(e *core.CollectionsListRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnCollectionsListRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
@@ -205,6 +230,32 @@ func TestCollectionView(t *testing.T) {
"OnCollectionViewRequest": 1,
},
},
{
Name: "OnCollectionViewRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/wsmn24bux7wo113",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionViewRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnCollectionViewRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
@@ -361,7 +412,7 @@ func TestCollectionDelete(t *testing.T) {
},
},
{
Name: "OnCollectionAfterDeleteSuccessRequest error response",
Name: "OnCollectionDeleteRequest tx body write check",
Method: http.MethodDelete,
URL: "/api/collections/view2",
Headers: map[string]string{
@@ -369,15 +420,22 @@ func TestCollectionDelete(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionDeleteRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionDeleteRequest": 1,
},
ExpectedEvents: map[string]int{"OnCollectionDeleteRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
@@ -656,7 +714,7 @@ func TestCollectionCreate(t *testing.T) {
},
},
{
Name: "OnCollectionCreateRequest error response",
Name: "OnCollectionCreateRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections",
Body: strings.NewReader(`{"name":"new","type":"base"}`),
@@ -665,15 +723,22 @@ func TestCollectionCreate(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionCreateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionCreateRequest": 1,
},
ExpectedEvents: map[string]int{"OnCollectionCreateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// view
@@ -978,7 +1043,7 @@ func TestCollectionUpdate(t *testing.T) {
},
},
{
Name: "OnCollectionAfterUpdateSuccessRequest error response",
Name: "OnCollectionUpdateRequest tx body write check",
Method: http.MethodPatch,
URL: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
@@ -987,15 +1052,22 @@ func TestCollectionUpdate(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionUpdateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionUpdateRequest": 1,
},
ExpectedEvents: map[string]int{"OnCollectionUpdateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "authorized as superuser + invalid data (eg. existing name)",
+7 -4
View File
@@ -19,7 +19,7 @@ import (
"golang.org/x/sync/singleflight"
)
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp"}
var defaultThumbSizes = []string{"100x100"}
// bindFileApi registers the file api endpoints and the corresponding handlers.
@@ -75,8 +75,8 @@ func (api *fileApi) fileToken(e *core.RequestEvent) error {
event.Record = e.Auth
return e.App.OnFileTokenRequest().Trigger(event, func(e *core.FileTokenRequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"token": e.Token,
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, map[string]string{"token": e.Token})
})
})
}
@@ -192,7 +192,10 @@ func (api *fileApi) download(e *core.RequestEvent) error {
e.Response.Header().Del("X-Frame-Options")
return e.App.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadRequestEvent) error {
if err := fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName); err != nil {
err = execAfterSuccessTx(true, e.App, func() error {
return fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName)
})
if err != nil {
return e.NotFoundError("", err)
}
+17 -3
View File
@@ -365,11 +365,25 @@ func logRequest(event *core.RequestEvent, err error) {
// parse the request error
if err != nil {
if apiErr, ok := err.(*router.ApiError); ok {
status = apiErr.Status
apiErr, isPlainApiError := err.(*router.ApiError)
if isPlainApiError || errors.As(err, &apiErr) {
// the status header wasn't written yet
if status == 0 {
status = apiErr.Status
}
var errMsg string
if isPlainApiError {
errMsg = apiErr.Message
} else {
// wrapped ApiError -> add the full serialized version
// of the original error since it could contain more information
errMsg = err.Error()
}
attrs = append(
attrs,
slog.String("error", apiErr.Message),
slog.String("error", errMsg),
slog.Any("details", apiErr.RawData()),
)
} else {
+78 -44
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 {
@@ -214,7 +213,9 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
slog.Any("subscriptions", e.Subscriptions),
)
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -353,7 +354,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 +376,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 +402,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 +422,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 +458,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 +480,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 +506,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 +531,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 +540,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 +570,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 +651,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 +686,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 +710,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,9 +756,9 @@ func realtimeCanAccessRecord(
return false
}
var exists bool
var exists int
q := app.DB().Select("(1)").
q := app.ConcurrentDB().Select("(1)").
From(record.Collection().Name).
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
@@ -739,5 +773,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])
}
}
}
})
}
}
+3 -1
View File
@@ -45,7 +45,9 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
+14 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -136,7 +135,7 @@ func TestRecordConfirmEmailChange(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
Name: "OnRecordConfirmEmailChangeRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-email-change",
Body: strings.NewReader(`{
@@ -145,15 +144,22 @@ func TestRecordConfirmEmailChange(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmEmailChangeRequest().BindFunc(func(e *core.RecordConfirmEmailChangeRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmEmailChangeRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmEmailChangeRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -43,7 +43,9 @@ func recordRequestEmailChange(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to request email change.", err))
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -118,6 +118,33 @@ func TestRecordRequestEmailChange(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestEmailChangeRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestEmailChangeRequest().BindFunc(func(e *core.RecordRequestEmailChangeRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestEmailChangeRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+2 -2
View File
@@ -26,10 +26,10 @@ func recordAuthImpersonate(e *core.RequestEvent) error {
form := &impersonateForm{}
if err = e.BindBody(form); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
return e.BadRequestError("An error occurred while loading the submitted data.", err)
}
if err = form.validate(); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
return e.BadRequestError("An error occurred while validating the submitted data.", err)
}
token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
+2 -2
View File
@@ -108,8 +108,8 @@ func recordRequestOTP(e *core.RequestEvent) error {
})
}
return e.JSON(http.StatusOK, map[string]string{
"otpId": otp.Id,
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, map[string]string{"otpId": otp.Id})
})
})
}
+25
View File
@@ -247,6 +247,31 @@ func TestRecordRequestOTP(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestOTPRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-otp",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestOTPRequest().BindFunc(func(e *core.RecordCreateOTPRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestOTPRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+3 -1
View File
@@ -54,7 +54,9 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -282,7 +281,7 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
},
},
{
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
Name: "OnRecordConfirmPasswordResetRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
@@ -292,15 +291,22 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmPasswordResetRequest().BindFunc(func(e *core.RecordConfirmPasswordResetRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmPasswordResetRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmPasswordResetRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -65,7 +65,9 @@ func recordRequestPasswordReset(e *core.RequestEvent) error {
})
})
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -101,6 +101,30 @@ func TestRecordRequestPasswordReset(t *testing.T) {
app.Store().Set(resendKey, struct{}{})
},
},
{
Name: "OnRecordRequestPasswordResetRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestPasswordResetRequest().BindFunc(func(e *core.RecordRequestPasswordResetRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestPasswordResetRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+15 -9
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"testing"
@@ -130,23 +129,30 @@ func TestRecordAuthRefresh(t *testing.T) {
},
},
{
Name: "OnRecordAfterAuthRefreshRequest error response",
Name: "OnRecordAuthRefreshRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-refresh?expand=rel,missing",
URL: "/api/collections/users/auth-refresh",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordAuthRefreshRequest().BindFunc(func(e *core.RecordAuthRefreshRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthRefreshRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordAuthRefreshRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+8 -8
View File
@@ -42,19 +42,19 @@ func recordConfirmVerification(e *core.RequestEvent) error {
event.Record = record
return e.App.OnRecordConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationRequestEvent) error {
if wasVerified {
return e.NoContent(http.StatusNoContent)
}
if !wasVerified {
e.Record.SetVerified(true)
e.Record.SetVerified(true)
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
}
}
e.App.Store().Remove(getVerificationResendKey(e.Record))
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
+14 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -144,7 +143,7 @@ func TestRecordConfirmVerification(t *testing.T) {
},
},
{
Name: "OnRecordAfterConfirmVerificationRequest error response",
Name: "OnRecordConfirmVerificationRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
@@ -152,15 +151,22 @@ func TestRecordConfirmVerification(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmVerificationRequest().BindFunc(func(e *core.RecordConfirmVerificationRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmVerificationRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmVerificationRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -68,7 +68,9 @@ func recordRequestVerification(e *core.RequestEvent) error {
})
})
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -118,6 +118,30 @@ func TestRecordRequestVerification(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestVerificationRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-verification",
Body: strings.NewReader(`{"email":"test@example.com"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestVerificationRequest().BindFunc(func(e *core.RecordRequestVerificationRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestVerificationRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+33 -12
View File
@@ -3,17 +3,18 @@ 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"
"golang.org/x/oauth2"
@@ -34,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 {
@@ -133,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)
})
@@ -194,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
}
}
@@ -221,7 +238,11 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload = map[string]any{}
}
payload[core.FieldNameEmail] = e.OAuth2User.Email
// 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)
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
+196 -11
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"`,
@@ -601,6 +619,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test_oauth2@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -706,6 +725,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -795,6 +815,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":true`,
`"email":""`,
`"id":"test_id"`,
`"verified":true`,
@@ -876,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
},
@@ -929,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,
@@ -948,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(`{
@@ -956,7 +975,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "invalid",
"email": "",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
@@ -993,6 +1012,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":""`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1040,6 +1060,95 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
},
},
{
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)",
Method: http.MethodPost,
@@ -1092,6 +1201,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1174,6 +1284,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1210,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(`{
@@ -1230,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",
},
@@ -1256,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":""`,
@@ -1294,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(`{
@@ -1314,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
@@ -1338,6 +1458,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1419,6 +1540,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1455,6 +1577,69 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordValidate": 4,
},
},
{
Name: "OnRecordAuthWithOAuth2Request tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
user.Collection().MFA.Enabled = false
user.Collection().OAuth2.Enabled = true
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
// stub linked provider
ea := core.NewExternalAuth(app)
ea.SetCollectionRef(user.Collection().Id)
ea.SetRecordRef(user.Id)
ea.SetProvider("test")
ea.SetProviderId("test_id")
if err := app.Save(ea); err != nil {
t.Fatal(err)
}
app.OnRecordAuthWithOAuth2Request().BindFunc(func(e *core.RecordAuthWithOAuth2RequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordAuthWithOAuth2Request": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+2
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
+61
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{
@@ -405,6 +419,53 @@ func TestRecordAuthWithOTP(t *testing.T) {
}
},
},
{
Name: "OnRecordAuthWithOTPRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
"otpId":"` + strings.Repeat("a", 15) + `",
"password":"123456"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// disable MFA
user.Collection().MFA.Enabled = false
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
otp := core.NewOTP(app)
otp.Id = strings.Repeat("a", 15)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
app.OnRecordAuthWithOTPRequest().BindFunc(func(e *core.RecordAuthWithOTPRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordAuthWithOTPRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+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
}
+207 -8
View File
@@ -1,18 +1,44 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
"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",
@@ -55,7 +81,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "OnRecordAuthWithPasswordRequest error response",
Name: "OnRecordAuthWithPasswordRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
@@ -64,15 +90,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordAuthWithPasswordRequest().BindFunc(func(e *core.RecordAuthWithPasswordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordAuthWithPasswordRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "valid identity field and invalid password",
@@ -99,6 +132,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 +212,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 +504,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
// -----------------------------------------------------------
{
+103 -88
View File
@@ -28,9 +28,9 @@ func bindRecordCrudApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
subGroup := rg.Group("/collections/{collection}/records").Unbind(DefaultRateLimitMiddlewareId)
subGroup.GET("", recordsList)
subGroup.GET("/{id}", recordView)
subGroup.POST("", recordCreate(nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.PATCH("/{id}", recordUpdate(nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.DELETE("/{id}", recordDelete(nil))
subGroup.POST("", recordCreate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.PATCH("/{id}", recordUpdate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.DELETE("/{id}", recordDelete(true, nil))
}
func recordsList(e *core.RequestEvent) error {
@@ -79,6 +79,11 @@ func recordsList(e *core.RequestEvent) error {
searchProvider := search.NewProvider(fieldsResolver).Query(query)
// use rowid when available to minimize the need of a covering index with the "id" field
if !collection.IsView() {
searchProvider.CountCol("_rowid_")
}
records := []*core.Record{}
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
if err != nil {
@@ -116,7 +121,9 @@ func recordsList(e *core.RequestEvent) error {
randomizedThrottle(150)
}
return e.JSON(http.StatusOK, e.Result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Result)
})
})
}
@@ -187,11 +194,13 @@ func recordView(e *core.RequestEvent) error {
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
return e.JSON(http.StatusOK, e.Record)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
})
}
func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordCreate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -251,6 +260,73 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
// check the request and record data against the create and manage rules
if !hasSuperuserAuth && collection.CreateRule != nil {
dummyRecord := record.Clone()
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
}
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
dummyRecord.SetVerified(false)
// 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 := *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.ConcurrentDB().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 e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
}
ruleQuery.AndWhere(expr)
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))
}
}
// check for manage rule access
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
form.GrantManagerAccess()
}
}
var isOptFinalizerCalled bool
event := new(core.RecordRequestEvent)
@@ -262,73 +338,6 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
form.SetApp(e.App)
form.SetRecord(e.Record)
// temporary save the record and check it against the create and manage rules
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
dummyRecord := e.Record.Clone()
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
}
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
dummyRecord.SetVerified(false)
// 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 e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
}
ruleQuery.AndWhere(expr)
resolver.UpdateQuery(ruleQuery)
var exists bool
err = ruleQuery.Limit(1).Row(&exists)
if err != nil || !exists {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
}
}
// 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))
@@ -339,7 +348,9 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
err = e.JSON(http.StatusOK, e.Record)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
if err != nil {
return err
}
@@ -369,7 +380,7 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordUpdate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -441,6 +452,14 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
form.Load(data)
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").From(collection.Name).AndWhere(dbx.HashExp{
collection.Name + ".id": record.Id,
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, collection, manageRuleQuery) {
form.GrantManagerAccess()
}
var isOptFinalizerCalled bool
event := new(core.RecordRequestEvent)
@@ -452,14 +471,6 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
form.SetApp(e.App)
form.SetRecord(e.Record)
manageRuleQuery := e.App.DB().Select("(1)").From(e.Collection.Name).AndWhere(dbx.HashExp{
e.Collection.Name + ".id": e.Record.Id,
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
form.GrantManagerAccess()
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to update record.", err))
@@ -470,7 +481,9 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
err = e.JSON(http.StatusOK, e.Record)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
if err != nil {
return err
}
@@ -500,7 +513,7 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordDelete(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -560,7 +573,9 @@ func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err))
}
err = e.NoContent(http.StatusNoContent)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
if err != nil {
return err
}
@@ -719,9 +734,9 @@ func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection
resolver.UpdateQuery(query)
var exists bool
var exists int
err = query.Limit(1).Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
+106 -42
View File
@@ -2,7 +2,6 @@ package apis_test
import (
"bytes"
"errors"
"net/http"
"net/url"
"os"
@@ -418,6 +417,32 @@ func TestRecordCrudList(t *testing.T) {
"OnRecordsListRequest": 1,
},
},
{
Name: "OnRecordsListRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/demo4/records",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordsListRequest().BindFunc(func(e *core.RecordsListRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
// -----------------------------------------------------------
@@ -862,6 +887,32 @@ func TestRecordCrudView(t *testing.T) {
"OnRecordEnrich": 7,
},
},
{
Name: "OnRecordViewRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/demo1/records/al1h9ijdeojtsjy",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordViewRequest().BindFunc(func(e *core.RecordRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
// -----------------------------------------------------------
@@ -1209,7 +1260,7 @@ func TestRecordCrudDelete(t *testing.T) {
},
},
{
Name: "OnRecordAfterDeleteSuccessRequest error response",
Name: "OnRecordDeleteRequest tx body write check",
Method: http.MethodDelete,
URL: "/api/collections/clients/records/o1y0dd0spd786md",
Headers: map[string]string{
@@ -1217,15 +1268,22 @@ func TestRecordCrudDelete(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordDeleteRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordDeleteRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "authenticated record that match the collection delete rule",
@@ -1563,10 +1621,7 @@ func TestRecordCrudCreate(t *testing.T) {
Body: strings.NewReader(`{"title":"test123"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule failure check)",
@@ -1579,10 +1634,7 @@ func TestRecordCrudCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule pass check) + expand relations",
@@ -1731,10 +1783,7 @@ func TestRecordCrudCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.body rule",
@@ -1801,21 +1850,31 @@ func TestRecordCrudCreate(t *testing.T) {
},
},
{
Name: "OnRecordAfterCreateSuccessRequest error response",
Name: "OnRecordCreateRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/demo2/records",
Body: strings.NewReader(`{"title":"new"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordCreateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// ID checks
@@ -1974,14 +2033,9 @@ func TestRecordCrudCreate(t *testing.T) {
"total+":4,
"total-":2
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "@request.body.field with compute modifers (rule pass check)",
@@ -2813,21 +2867,31 @@ func TestRecordCrudUpdate(t *testing.T) {
},
},
{
Name: "OnRecordAfterUpdateSuccessRequest error response",
Name: "OnRecordUpdateRequest tx body write check",
Method: http.MethodPatch,
URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{"title":"new"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordUpdateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordUpdateRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "try to change the id of an existing record",
+28 -5
View File
@@ -129,7 +129,9 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
result.Meta = e.Meta
}
return e.JSON(http.StatusOK, result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, result)
})
})
}
@@ -146,7 +148,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)").
@@ -165,7 +167,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.
@@ -455,8 +457,8 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
managedIds := []string{}
query := app.RecordQuery(collection).
Select(app.DB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(app.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
Select(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
expr, err := search.FilterData(*collection.ManageRule).BuildExpr(resolver)
@@ -535,6 +537,27 @@ func firstApiError(errs ...error) *router.ApiError {
return router.NewInternalServerError("", errors.Join(errs...))
}
// execAfterSuccessTx ensures that fn is executed only after a succesul transaction.
//
// If the current app instance is not a transactional or checkTx is false,
// then fn is directly executed.
//
// It could be usually used to allow propagating an error or writing
// custom response from within the wrapped transaction block.
func execAfterSuccessTx(checkTx bool, app core.App, fn func() error) error {
if txInfo := app.TxInfo(); txInfo != nil && checkTx {
txInfo.OnComplete(func(txErr error) error {
if txErr == nil {
return fn()
}
return nil
})
return nil
}
return fn()
}
// -------------------------------------------------------------------
const maxAuthOrigins = 5
+5 -4
View File
@@ -86,7 +86,7 @@ func Serve(app core.App, config ServeConfig) error {
// add a default CSP
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
}
return e.Next()
@@ -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
@@ -259,6 +259,7 @@ func Serve(app core.App, config ServeConfig) error {
}
if listener == nil {
//nolint:staticcheck
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
}
+6 -2
View File
@@ -30,7 +30,9 @@ func settingsList(e *core.RequestEvent) error {
event.Settings = clone
return e.App.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListRequestEvent) error {
return e.JSON(http.StatusOK, e.Settings)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Settings)
})
})
}
@@ -65,7 +67,9 @@ func settingsSet(e *core.RequestEvent) error {
return e.InternalServerError("Failed to clone app settings.", err)
}
return e.JSON(http.StatusOK, appSettings)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, appSettings)
})
})
}
+54
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
@@ -58,6 +59,32 @@ func TestSettingsList(t *testing.T) {
"OnSettingsListRequest": 1,
},
},
{
Name: "OnSettingsListRequest tx body write check",
Method: http.MethodGet,
URL: "/api/settings",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnSettingsListRequest().BindFunc(func(e *core.SettingsListRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnSettingsListRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
@@ -176,6 +203,33 @@ func TestSettingsSet(t *testing.T) {
"OnSettingsReload": 1,
},
},
{
Name: "OnSettingsUpdateRequest tx body write check",
Method: http.MethodPatch,
URL: "/api/settings",
Body: strings.NewReader(validData),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnSettingsUpdateRequest().BindFunc(func(e *core.SettingsUpdateRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnSettingsUpdateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
+19 -19
View File
@@ -36,16 +36,16 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
return errors.New("missing email and password arguments")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
return errors.New("missing or invalid email address")
}
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
}
superuser, err := app.FindAuthRecordByEmail(superusersCol, args[0])
@@ -57,7 +57,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
superuser.SetPassword(args[1])
if err := app.Save(superuser); err != nil {
return fmt.Errorf("Failed to upsert superuser account: %w.", err)
return fmt.Errorf("failed to upsert superuser account: %w", err)
}
color.Green("Successfully saved superuser %q!", superuser.Email())
@@ -76,16 +76,16 @@ func superuserCreateCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
return errors.New("missing email and password arguments")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
return errors.New("missing or invalid email address")
}
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
}
superuser := core.NewRecord(superusersCol)
@@ -93,7 +93,7 @@ func superuserCreateCommand(app core.App) *cobra.Command {
superuser.SetPassword(args[1])
if err := app.Save(superuser); err != nil {
return fmt.Errorf("Failed to create new superuser account: %w.", err)
return fmt.Errorf("failed to create new superuser account: %w", err)
}
color.Green("Successfully created new superuser %q!", superuser.Email())
@@ -112,22 +112,22 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
return errors.New("missing email and password arguments")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
return errors.New("missing or invalid email address")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
}
superuser.SetPassword(args[1])
if err := app.Save(superuser); err != nil {
return fmt.Errorf("Failed to change superuser %q password: %w.", superuser.Email(), err)
return fmt.Errorf("failed to change superuser %q password: %w", superuser.Email(), err)
}
color.Green("Successfully changed superuser %q password!", superuser.Email())
@@ -146,17 +146,17 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.")
return errors.New("invalid or missing email address")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
color.Yellow("Superuser %q is missing or already deleted.", args[0])
color.Yellow("superuser %q is missing or already deleted", args[0])
return nil
}
if err := app.Delete(superuser); err != nil {
return fmt.Errorf("Failed to delete superuser %q: %w.", superuser.Email(), err)
return fmt.Errorf("failed to delete superuser %q: %w", superuser.Email(), err)
}
color.Green("Successfully deleted superuser %q!", superuser.Email())
@@ -175,16 +175,16 @@ func superuserOTPCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.")
return errors.New("invalid or missing email address")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
}
if !superuser.Collection().OTP.Enabled {
return errors.New("OTP is not enabled for the _superusers collection.")
return errors.New("OTP auth is not enabled for the _superusers collection")
}
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
@@ -196,7 +196,7 @@ func superuserOTPCommand(app core.App) *cobra.Command {
err = app.Save(otp)
if err != nil {
return fmt.Errorf("Failed to create OTP: %w", err)
return fmt.Errorf("failed to create OTP: %w", err)
}
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
+62 -22
View File
@@ -45,6 +45,12 @@ type App interface {
// IsTransactional checks if the current app instance is part of a transaction.
IsTransactional() bool
// TxInfo returns the transaction associated with the current app instance (if any).
//
// Could be used if you want to execute indirectly a function after
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
TxInfo() *TxAppInfo
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
@@ -91,7 +97,7 @@ type App interface {
// after you are done working with it.
NewFilesystem() (*filesystem.System, error)
// NewFilesystem creates a new local or S3 filesystem instance
// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
@@ -140,46 +146,82 @@ type App interface {
// DB methods
// ---------------------------------------------------------------
// DB returns the default app data db instance (pb_data/data.db).
// DB returns the default app data.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything else
// to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly ConcurrentDB() or NonconcurrentDB().
DB() dbx.Builder
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
// ConcurrentDB returns the concurrent app data.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
ConcurrentDB() dbx.Builder
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
NonconcurrentDB() dbx.Builder
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
// AuxDB returns the app auxiliary.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything else
// to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
AuxDB() dbx.Builder
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db)..
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
AuxConcurrentDB() dbx.Builder
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
AuxNonconcurrentDB() dbx.Builder
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
// in the current app.DB() instance.
// in the data.db.
HasTable(tableName string) bool
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
// in the current app.AuxDB() instance.
// in the auxiliary.db.
AuxHasTable(tableName string) bool
// TableColumns returns all column names of a single table by its name.
@@ -225,21 +267,19 @@ type App interface {
// FindRecordByViewFile returns the original Record of the provided view collection file.
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
// Vacuum executes VACUUM on the current app.DB() instance
// in order to reclaim unused data db disk space.
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
Vacuum() error
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
// in order to reclaim unused auxiliary db disk space.
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
AuxVacuum() error
// ---------------------------------------------------------------
// ModelQuery creates a new preconfigured select app.DB() query with preset
// ModelQuery creates a new preconfigured select data.db query with preset
// SELECT, FROM and other common fields based on the provided model.
ModelQuery(model Model) *dbx.SelectQuery
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
// SELECT, FROM and other common fields based on the provided model.
AuxModelQuery(model Model) *dbx.SelectQuery
@@ -323,10 +363,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
// ---------------------------------------------------------------
+151 -41
View File
@@ -26,6 +26,8 @@ import (
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/sync/semaphore"
)
const (
@@ -69,7 +71,7 @@ var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct {
config *BaseAppConfig
txInfo *txAppInfo
txInfo *TxAppInfo
store *store.Store[string, any]
cron *cron.Cron
settings *Settings
@@ -360,9 +362,17 @@ func (app *BaseApp) Logger() *slog.Logger {
return app.logger
}
// TxInfo returns the transaction associated with the current app instance (if any).
//
// Could be used if you want to execute indirectly a function after
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
func (app *BaseApp) TxInfo() *TxAppInfo {
return app.txInfo
}
// IsTransactional checks if the current app instance is part of a transaction.
func (app *BaseApp) IsTransactional() bool {
return app.txInfo != nil
return app.TxInfo() != nil
}
// IsBootstrapped checks if the application was initialized
@@ -466,44 +476,100 @@ func (app *BaseApp) ResetBootstrapState() error {
return nil
}
// DB returns the default app data db instance (pb_data/data.db).
// DB returns the default app data.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly ConcurrentDB() or NonconcurrentDB().
func (app *BaseApp) DB() dbx.Builder {
// transactional or both are nil
if app.concurrentDB == app.nonconcurrentDB {
return app.concurrentDB
}
return &dualDBBuilder{
concurrentDB: app.concurrentDB,
nonconcurrentDB: app.nonconcurrentDB,
}
}
// ConcurrentDB returns the concurrent app data.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) ConcurrentDB() dbx.Builder {
return app.concurrentDB
}
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) NonconcurrentDB() dbx.Builder {
return app.nonconcurrentDB
}
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
// AuxDB returns the app auxiliary.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
func (app *BaseApp) AuxDB() dbx.Builder {
// transactional or both are nil
if app.auxConcurrentDB == app.auxNonconcurrentDB {
return app.auxConcurrentDB
}
return &dualDBBuilder{
concurrentDB: app.auxConcurrentDB,
nonconcurrentDB: app.auxNonconcurrentDB,
}
}
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxConcurrentDB() dbx.Builder {
return app.auxConcurrentDB
}
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db).
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
return app.auxNonconcurrentDB
}
@@ -659,7 +725,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
// NewFilesystem creates a new local or S3 filesystem instance
// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
@@ -1139,26 +1205,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
@@ -1191,6 +1256,33 @@ func (app *BaseApp) initAuxDB() error {
return nil
}
// @todo remove after refactoring the FilesManager interface
func supportFiles(m Model) bool {
var collection *Collection
switch v := m.(type) {
case *Collection:
collection = v
case *Record:
collection = v.Collection()
case RecordProxy:
if v.ProxyRecord() != nil {
collection = v.ProxyRecord().Collection()
}
}
if collection == nil {
return true
}
for _, f := range collection.Fields {
if f.Type() == FieldTypeFile {
return true
}
}
return false
}
func (app *BaseApp) registerBaseHooks() {
deletePrefix := func(prefix string) error {
fs, err := app.NewFilesystem()
@@ -1207,26 +1299,44 @@ func (app *BaseApp) registerBaseHooks() {
return nil
}
maxFilesDeleteWorkers := cast.ToInt64(os.Getenv("PB_FILES_DELETE_MAX_WORKERS"))
if maxFilesDeleteWorkers <= 0 {
maxFilesDeleteWorkers = 2000 // the value is arbitrary chosen and may change in the future
}
deleteSem := semaphore.NewWeighted(maxFilesDeleteWorkers)
// try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
Id: "__pbFilesManagerDelete__",
Func: func(e *ModelEvent) error {
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" {
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" && supportFiles(e.Model) {
// ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
// (https://github.com/pocketbase/pocketbase/discussions/5246#discussioncomment-10128955)
prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"
// run in the background for "optimistic" delete to avoid
// blocking the delete transaction
routine.FireAndForget(func() {
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
}
})
// note: for now assume no context cancellation
err := deleteSem.Acquire(context.Background(), 1)
if err != nil {
app.Logger().Error(
"Failed to delete storage prefix (couldn't acquire a worker)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
} else {
// run in the background for "optimistic" delete to avoid blocking the delete transaction
routine.FireAndForget(func() {
defer deleteSem.Release(1)
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
}
})
}
}
return e.Next()
@@ -1255,7 +1365,7 @@ func (app *BaseApp) registerBaseHooks() {
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
}
_, execErr = app.DB().NewQuery("PRAGMA optimize").Execute()
_, execErr = app.ConcurrentDB().NewQuery("PRAGMA optimize").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
}
+1 -1
View File
@@ -190,7 +190,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// extract the zip
if e.App.Settings().Backups.S3.Enabled {
br, err := fsys.GetFile(name)
br, err := fsys.GetReader(name)
if err != nil {
return err
}
+185 -11
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"log/slog"
"os"
"slices"
"testing"
"time"
@@ -99,9 +100,11 @@ func TestBaseAppBootstrap(t *testing.T) {
}
nilChecksBeforeReset := []nilCheck{
{"[before] concurrentDB", app.DB(), false},
{"[before] db", app.DB(), false},
{"[before] concurrentDB", app.ConcurrentDB(), false},
{"[before] nonconcurrentDB", app.NonconcurrentDB(), false},
{"[before] auxConcurrentDB", app.AuxDB(), false},
{"[before] auxDB", app.AuxDB(), false},
{"[before] auxConcurrentDB", app.AuxConcurrentDB(), false},
{"[before] auxNonconcurrentDB", app.AuxNonconcurrentDB(), false},
{"[before] settings", app.Settings(), false},
{"[before] logger", app.Logger(), false},
@@ -116,9 +119,11 @@ func TestBaseAppBootstrap(t *testing.T) {
}
nilChecksAfterReset := []nilCheck{
{"[after] concurrentDB", app.DB(), true},
{"[after] db", app.DB(), true},
{"[after] concurrentDB", app.ConcurrentDB(), true},
{"[after] nonconcurrentDB", app.NonconcurrentDB(), true},
{"[after] auxConcurrentDB", app.AuxDB(), true},
{"[after] auxDB", app.AuxDB(), true},
{"[after] auxConcurrentDB", app.AuxConcurrentDB(), true},
{"[after] auxNonconcurrentDB", app.AuxNonconcurrentDB(), true},
{"[after] settings", app.Settings(), false},
{"[after] logger", app.Logger(), false},
@@ -128,7 +133,7 @@ func TestBaseAppBootstrap(t *testing.T) {
runNilChecks(nilChecksAfterReset)
}
func TestNewBaseAppIsTransactional(t *testing.T) {
func TestNewBaseAppTx(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
@@ -141,17 +146,34 @@ func TestNewBaseAppIsTransactional(t *testing.T) {
t.Fatal(err)
}
if app.IsTransactional() {
t.Fatalf("Didn't expect the app to be transactional")
mustNotHaveTx := func(app core.App) {
if app.IsTransactional() {
t.Fatalf("Didn't expect the app to be transactional")
}
if app.TxInfo() != nil {
t.Fatalf("Didn't expect the app.txInfo to be loaded")
}
}
app.RunInTransaction(func(txApp core.App) error {
if !txApp.IsTransactional() {
mustHaveTx := func(app core.App) {
if !app.IsTransactional() {
t.Fatalf("Expected the app to be transactional")
}
if app.TxInfo() == nil {
t.Fatalf("Expected the app.txInfo to be loaded")
}
}
mustNotHaveTx(app)
app.RunInTransaction(func(txApp core.App) error {
mustHaveTx(txApp)
return nil
})
mustNotHaveTx(app)
}
func TestBaseAppNewMailClient(t *testing.T) {
@@ -354,8 +376,8 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
}
// silence query logs
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
@@ -378,3 +400,155 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
})
}
}
func TestBaseAppDBDualBuilder(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
concurrentQueries := []string{}
nonconcurrentQueries := []string{}
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
type testQuery struct {
query string
isConcurrent bool
}
regularTests := []testQuery{
{" \n sEleCt 1", true},
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
{"create table t1(x int)", false},
{"insert into t1(x) values(1)", false},
{"update t1 set x = 2", false},
{"delete from t1", false},
}
txTests := []testQuery{
{"select 3", false},
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
{"create table t2(x int)", false},
{"insert into t2(x) values(1)", false},
{"update t2 set x = 2", false},
{"delete from t2", false},
}
for _, item := range regularTests {
_, err := app.DB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
app.RunInTransaction(func(txApp core.App) error {
for _, item := range txTests {
_, err := txApp.DB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
return nil
})
allTests := append(regularTests, txTests...)
for _, item := range allTests {
if item.isConcurrent {
if !slices.Contains(concurrentQueries, item.query) {
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
} else {
if !slices.Contains(nonconcurrentQueries, item.query) {
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
}
}
}
func TestBaseAppAuxDBDualBuilder(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
concurrentQueries := []string{}
nonconcurrentQueries := []string{}
app.AuxConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.AuxConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
type testQuery struct {
query string
isConcurrent bool
}
regularTests := []testQuery{
{" \n sEleCt 1", true},
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
{"create table t1(x int)", false},
{"insert into t1(x) values(1)", false},
{"update t1 set x = 2", false},
{"delete from t1", false},
}
txTests := []testQuery{
{"select 3", false},
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
{"create table t2(x int)", false},
{"insert into t2(x) values(1)", false},
{"update t2 set x = 2", false},
{"delete from t2", false},
}
for _, item := range regularTests {
_, err := app.AuxDB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
app.AuxRunInTransaction(func(txApp core.App) error {
for _, item := range txTests {
_, err := txApp.AuxDB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
return nil
})
allTests := append(regularTests, txTests...)
for _, item := range allTests {
if item.isConcurrent {
if !slices.Contains(concurrentQueries, item.query) {
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
} else {
if !slices.Contains(nonconcurrentQueries, item.query) {
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
}
}
}
+1 -1
View File
@@ -170,7 +170,7 @@ func TestImportCollections(t *testing.T) {
expectedCollectionFields := map[string]int{
core.CollectionNameAuthOrigins: 6,
"nologin": 10,
"demo1": 18,
"demo1": 19,
"demo2": 5,
"demo3": 5,
"demo4": 16,
+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),
+279 -1
View File
@@ -1,9 +1,12 @@
package core_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"testing"
@@ -11,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -976,6 +980,280 @@ func TestCollectionDelete(t *testing.T) {
}
}
func TestCollectionModelEventSync(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
testCollections := make([]*core.Collection, 4)
for i := 0; i < 4; i++ {
testCollections[i] = core.NewBaseCollection("sync_test_" + strconv.Itoa(i))
if err := app.Save(testCollections[i]); err != nil {
t.Fatal(err)
}
}
createModelEvent := func() *core.ModelEvent {
event := new(core.ModelEvent)
event.App = app
event.Context = context.Background()
event.Type = "test_a"
event.Model = testCollections[0]
return event
}
createModelErrorEvent := func() *core.ModelErrorEvent {
event := new(core.ModelErrorEvent)
event.ModelEvent = *createModelEvent()
event.Error = errors.New("error_a")
return event
}
changeCollectionEventBefore := func(e *core.CollectionEvent) {
e.Type = "test_b"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 123)
e.Collection = testCollections[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testCollections[2]
}
changeCollectionEventAfter := func(e *core.CollectionEvent) {
e.Type = "test_d"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 789)
e.Collection = testCollections[3]
}
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Model.PK() != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Model.PK(), 0)
}
}
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_d" {
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
}
if v := e.Context.Value("test"); v != 789 {
t.Fatalf("Expected context value %v, got %v", 789, v)
}
if e.Model.PK() != testCollections[3].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[3].Id, e.Model.PK(), 3)
}
}
expectedBeforeCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Collection.Id != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Collection.Id, 0)
}
}
expectedAfterCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_c" {
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
}
if v := e.Context.Value("test"); v != 456 {
t.Fatalf("Expected context value %v, got %v", 456, v)
}
if e.Collection.Id != testCollections[2].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[2].Id, e.Collection.Id, 2)
}
}
modelEventFinalizer := func(e *core.ModelEvent) error {
modelEventFinalizerChange(e)
return nil
}
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
modelEventFinalizerChange(&e.ModelEvent)
e.Error = errors.New("error_c")
return nil
}
modelEventHandler := &hook.Handler[*core.ModelEvent]{
Priority: -999,
Func: func(e *core.ModelEvent) error {
t.Run("before model", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, e)
})
_ = e.Next()
t.Run("after model", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, e)
})
return nil
},
}
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
Priority: -999,
Func: func(e *core.ModelErrorEvent) error {
t.Run("before model error", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_a", v)
}
})
_ = e.Next()
t.Run("after model error", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_d" {
t.Fatalf("Expected error %q, got %q", "error_d", v)
}
})
return nil
},
}
recordEventHandler := &hook.Handler[*core.CollectionEvent]{
Priority: -999,
Func: func(e *core.CollectionEvent) error {
t.Run("before collection", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, e)
})
changeCollectionEventBefore(e)
_ = e.Next()
t.Run("after collection", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, e)
})
changeCollectionEventAfter(e)
return nil
},
}
collectionErrorEventHandler := &hook.Handler[*core.CollectionErrorEvent]{
Priority: -999,
Func: func(e *core.CollectionErrorEvent) error {
t.Run("before collection error", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventBefore(&e.CollectionEvent)
e.Error = errors.New("error_b")
_ = e.Next()
t.Run("after collection error", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_c" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventAfter(&e.CollectionEvent)
e.Error = errors.New("error_d")
return nil
},
}
// OnModelValidate
app.OnCollectionValidate().Bind(recordEventHandler)
app.OnModelValidate().Bind(modelEventHandler)
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreate
app.OnCollectionCreate().Bind(recordEventHandler)
app.OnModelCreate().Bind(modelEventHandler)
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreateExecute
app.OnCollectionCreateExecute().Bind(recordEventHandler)
app.OnModelCreateExecute().Bind(modelEventHandler)
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateSuccess
app.OnCollectionAfterCreateSuccess().Bind(recordEventHandler)
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateError
app.OnCollectionAfterCreateError().Bind(collectionErrorEventHandler)
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelUpdate
app.OnCollectionUpdate().Bind(recordEventHandler)
app.OnModelUpdate().Bind(modelEventHandler)
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelUpdateExecute
app.OnCollectionUpdateExecute().Bind(recordEventHandler)
app.OnModelUpdateExecute().Bind(modelEventHandler)
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateSuccess
app.OnCollectionAfterUpdateSuccess().Bind(recordEventHandler)
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateError
app.OnCollectionAfterUpdateError().Bind(collectionErrorEventHandler)
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelDelete
app.OnCollectionDelete().Bind(recordEventHandler)
app.OnModelDelete().Bind(modelEventHandler)
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelDeleteExecute
app.OnCollectionDeleteExecute().Bind(recordEventHandler)
app.OnModelDeleteExecute().Bind(modelEventHandler)
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteSuccess
app.OnCollectionAfterDeleteSuccess().Bind(recordEventHandler)
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteError
app.OnCollectionAfterDeleteError().Bind(collectionErrorEventHandler)
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
}
func TestCollectionSaveModel(t *testing.T) {
t.Parallel()
@@ -1345,7 +1623,7 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
var sql string
rowErr := app.DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
rowErr := app.ConcurrentDB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
Bind(dbx.Params{"name": viewName}).
Row(&sql)
if rowErr != nil {
+4 -4
View File
@@ -38,7 +38,7 @@ func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection
q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
}
err := q.OrderBy("created ASC").All(&collections)
err := q.OrderBy("rowid ASC").All(&collections)
if err != nil {
return nil, err
}
@@ -206,9 +206,9 @@ func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bo
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
var total int
return query.Row(&exists) == nil && !exists
return query.Row(&total) == nil && total == 0
}
// TruncateCollection deletes all records associated with the provided collection.
@@ -220,7 +220,7 @@ func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bo
// cascade and file delete actions.
func (app *BaseApp) TruncateCollection(collection *Collection) error {
if collection.IsView() {
return errors.New("view collections cannot be truncated since they don't store their own records.")
return errors.New("view collections cannot be truncated since they don't store their own records")
}
return app.RunInTransaction(func(txApp App) error {
+2 -2
View File
@@ -153,7 +153,7 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
defer app.Cleanup()
totalQueries := 0
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
@@ -272,7 +272,7 @@ func TestFindCachedCollectionReferences(t *testing.T) {
}
totalQueries := 0
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
+3 -2
View File
@@ -144,7 +144,7 @@ func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollecti
// run optimize per the SQLite recommendations
// (https://www.sqlite.org/pragma.html#pragma_optimize)
_, optimizeErr := app.DB().NewQuery("PRAGMA optimize").Execute()
_, optimizeErr := app.ConcurrentDB().NewQuery("PRAGMA optimize").Execute()
if optimizeErr != nil {
app.Logger().Warn("Failed to run PRAGMA optimize after record table sync", slog.String("error", optimizeErr.Error()))
}
@@ -310,7 +310,8 @@ func dropCollectionIndexes(app App, collection *Collection) error {
continue
}
if _, err := app.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
if err != nil {
return err
}
}
+9 -5
View File
@@ -34,7 +34,11 @@ func onCollectionValidate(e *CollectionEvent) error {
original,
)
return validator.run()
if err := validator.run(); err != nil {
return err
}
return e.Next()
}
func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator {
@@ -83,7 +87,7 @@ func (validator *collectionValidator) run() error {
validator.original.IsNew(),
validation.Length(1, 100),
validation.Match(DefaultIdRegex),
validation.By(validators.UniqueId(validator.app.DB(), validator.new.TableName())),
validation.By(validators.UniqueId(validator.app.ConcurrentDB(), validator.new.TableName())),
).Else(
validation.By(validators.Equal(validator.original.Id)),
),
@@ -456,7 +460,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})
}
@@ -554,7 +558,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
// ensure that the index name is not used in another collection
var usedTblName string
_ = cv.app.DB().Select("tbl_name").
_ = cv.app.ConcurrentDB().Select("tbl_name").
From("sqlite_master").
AndWhere(dbx.HashExp{"type": "index"}).
AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})).
@@ -666,7 +670,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}}".`,
+11 -12
View File
@@ -62,16 +62,16 @@ func crc32Checksum(str string) string {
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
}
// ModelQuery creates a new preconfigured select app.DB() query with preset
// ModelQuery creates a new preconfigured select data.db query with preset
// SELECT, FROM and other common fields based on the provided model.
func (app *BaseApp) ModelQuery(m Model) *dbx.SelectQuery {
return app.modelQuery(app.DB(), m)
return app.modelQuery(app.ConcurrentDB(), m)
}
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
// SELECT, FROM and other common fields based on the provided model.
func (app *BaseApp) AuxModelQuery(m Model) *dbx.SelectQuery {
return app.modelQuery(app.AuxDB(), m)
return app.modelQuery(app.AuxConcurrentDB(), m)
}
func (app *BaseApp) modelQuery(db dbx.Builder, m Model) *dbx.SelectQuery {
@@ -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")
}
@@ -152,7 +151,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -343,7 +342,7 @@ func (app *BaseApp) create(ctx context.Context, model Model, withValidations boo
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -427,7 +426,7 @@ func (app *BaseApp) update(ctx context.Context, model Model, withValidations boo
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -483,15 +482,15 @@ 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)").
rowErr := app.ConcurrentDB().Select("(1)").
From(collection.Name).
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
Row(&exists)
if rowErr != nil || !exists {
if rowErr != nil || exists == 0 {
return validation.NewError("validation_invalid_record", "Missing or invalid record.")
}
+187
View File
@@ -0,0 +1,187 @@
package core
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/pocketbase/dbx"
)
var _ dbx.Builder = (*dualDBBuilder)(nil)
// note: expects both builder to use the same driver
type dualDBBuilder struct {
concurrentDB dbx.Builder
nonconcurrentDB dbx.Builder
}
// Select implements the [dbx.Builder.Select] interface method.
func (b *dualDBBuilder) Select(cols ...string) *dbx.SelectQuery {
return b.concurrentDB.Select(cols...)
}
// Model implements the [dbx.Builder.Model] interface method.
func (b *dualDBBuilder) Model(data interface{}) *dbx.ModelQuery {
return b.nonconcurrentDB.Model(data)
}
// GeneratePlaceholder implements the [dbx.Builder.GeneratePlaceholder] interface method.
func (b *dualDBBuilder) GeneratePlaceholder(i int) string {
return b.concurrentDB.GeneratePlaceholder(i)
}
// Quote implements the [dbx.Builder.Quote] interface method.
func (b *dualDBBuilder) Quote(str string) string {
return b.concurrentDB.Quote(str)
}
// QuoteSimpleTableName implements the [dbx.Builder.QuoteSimpleTableName] interface method.
func (b *dualDBBuilder) QuoteSimpleTableName(table string) string {
return b.concurrentDB.QuoteSimpleTableName(table)
}
// QuoteSimpleColumnName implements the [dbx.Builder.QuoteSimpleColumnName] interface method.
func (b *dualDBBuilder) QuoteSimpleColumnName(col string) string {
return b.concurrentDB.QuoteSimpleColumnName(col)
}
// QueryBuilder implements the [dbx.Builder.QueryBuilder] interface method.
func (b *dualDBBuilder) QueryBuilder() dbx.QueryBuilder {
return b.concurrentDB.QueryBuilder()
}
// Insert implements the [dbx.Builder.Insert] interface method.
func (b *dualDBBuilder) Insert(table string, cols dbx.Params) *dbx.Query {
return b.nonconcurrentDB.Insert(table, cols)
}
// Upsert implements the [dbx.Builder.Upsert] interface method.
func (b *dualDBBuilder) Upsert(table string, cols dbx.Params, constraints ...string) *dbx.Query {
return b.nonconcurrentDB.Upsert(table, cols, constraints...)
}
// Update implements the [dbx.Builder.Update] interface method.
func (b *dualDBBuilder) Update(table string, cols dbx.Params, where dbx.Expression) *dbx.Query {
return b.nonconcurrentDB.Update(table, cols, where)
}
// Delete implements the [dbx.Builder.Delete] interface method.
func (b *dualDBBuilder) Delete(table string, where dbx.Expression) *dbx.Query {
return b.nonconcurrentDB.Delete(table, where)
}
// CreateTable implements the [dbx.Builder.CreateTable] interface method.
func (b *dualDBBuilder) CreateTable(table string, cols map[string]string, options ...string) *dbx.Query {
return b.nonconcurrentDB.CreateTable(table, cols, options...)
}
// RenameTable implements the [dbx.Builder.RenameTable] interface method.
func (b *dualDBBuilder) RenameTable(oldName, newName string) *dbx.Query {
return b.nonconcurrentDB.RenameTable(oldName, newName)
}
// DropTable implements the [dbx.Builder.DropTable] interface method.
func (b *dualDBBuilder) DropTable(table string) *dbx.Query {
return b.nonconcurrentDB.DropTable(table)
}
// TruncateTable implements the [dbx.Builder.TruncateTable] interface method.
func (b *dualDBBuilder) TruncateTable(table string) *dbx.Query {
return b.nonconcurrentDB.TruncateTable(table)
}
// AddColumn implements the [dbx.Builder.AddColumn] interface method.
func (b *dualDBBuilder) AddColumn(table, col, typ string) *dbx.Query {
return b.nonconcurrentDB.AddColumn(table, col, typ)
}
// DropColumn implements the [dbx.Builder.DropColumn] interface method.
func (b *dualDBBuilder) DropColumn(table, col string) *dbx.Query {
return b.nonconcurrentDB.DropColumn(table, col)
}
// RenameColumn implements the [dbx.Builder.RenameColumn] interface method.
func (b *dualDBBuilder) RenameColumn(table, oldName, newName string) *dbx.Query {
return b.nonconcurrentDB.RenameColumn(table, oldName, newName)
}
// AlterColumn implements the [dbx.Builder.AlterColumn] interface method.
func (b *dualDBBuilder) AlterColumn(table, col, typ string) *dbx.Query {
return b.nonconcurrentDB.AlterColumn(table, col, typ)
}
// AddPrimaryKey implements the [dbx.Builder.AddPrimaryKey] interface method.
func (b *dualDBBuilder) AddPrimaryKey(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.AddPrimaryKey(table, name, cols...)
}
// DropPrimaryKey implements the [dbx.Builder.DropPrimaryKey] interface method.
func (b *dualDBBuilder) DropPrimaryKey(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropPrimaryKey(table, name)
}
// AddForeignKey implements the [dbx.Builder.AddForeignKey] interface method.
func (b *dualDBBuilder) AddForeignKey(table, name string, cols, refCols []string, refTable string, options ...string) *dbx.Query {
return b.nonconcurrentDB.AddForeignKey(table, name, cols, refCols, refTable, options...)
}
// DropForeignKey implements the [dbx.Builder.DropForeignKey] interface method.
func (b *dualDBBuilder) DropForeignKey(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropForeignKey(table, name)
}
// CreateIndex implements the [dbx.Builder.CreateIndex] interface method.
func (b *dualDBBuilder) CreateIndex(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.CreateIndex(table, name, cols...)
}
// CreateUniqueIndex implements the [dbx.Builder.CreateUniqueIndex] interface method.
func (b *dualDBBuilder) CreateUniqueIndex(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.CreateUniqueIndex(table, name, cols...)
}
// DropIndex implements the [dbx.Builder.DropIndex] interface method.
func (b *dualDBBuilder) DropIndex(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropIndex(table, name)
}
// NewQuery implements the [dbx.Builder.NewQuery] interface method by
// routing the SELECT queries to the concurrent builder instance.
func (b *dualDBBuilder) NewQuery(str string) *dbx.Query {
// note: technically INSERT/UPDATE/DELETE could also have CTE but since
// it is rare for now this scase is ignored to avoid unnecessary complicating the checks
trimmed := trimLeftSpaces(str)
if hasPrefixFold(trimmed, "SELECT") || hasPrefixFold(trimmed, "WITH") {
return b.concurrentDB.NewQuery(str)
}
return b.nonconcurrentDB.NewQuery(str)
}
var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}
// note: similar to strings.Space() but without the right trim because it is not needed in our case
func trimLeftSpaces(str string) string {
start := 0
for ; start < len(str); start++ {
c := str[start]
if c >= utf8.RuneSelf {
return strings.TrimLeftFunc(str[start:], unicode.IsSpace)
}
if asciiSpace[c] == 0 {
break
}
}
return str[start:]
}
// note: the prefix is expected to be ASCII
func hasPrefixFold(str, prefix string) bool {
if len(str) < len(prefix) {
return false
}
return strings.EqualFold(str[:len(prefix)], prefix)
}
+14 -16
View File
@@ -11,7 +11,7 @@ import (
func (app *BaseApp) TableColumns(tableName string) ([]string, error) {
columns := []string{}
err := app.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
err := app.ConcurrentDB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
Column(&columns)
@@ -34,7 +34,7 @@ type TableInfoRow struct {
func (app *BaseApp) TableInfo(tableName string) ([]*TableInfoRow, error) {
info := []*TableInfoRow{}
err := app.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
err := app.ConcurrentDB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
All(&info)
if err != nil {
@@ -59,7 +59,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
Sql string
}{}
err := app.DB().Select("name", "sql").
err := app.ConcurrentDB().Select("name", "sql").
From("sqlite_master").
AndWhere(dbx.NewExp("sql is not null")).
AndWhere(dbx.HashExp{
@@ -87,7 +87,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
// NB! Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
func (app *BaseApp) DeleteTable(tableName string) error {
_, err := app.DB().NewQuery(fmt.Sprintf(
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}",
tableName,
)).Execute()
@@ -96,19 +96,19 @@ func (app *BaseApp) DeleteTable(tableName string) error {
}
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
// in the current app.DB() instance.
// in the data.db.
func (app *BaseApp) HasTable(tableName string) bool {
return app.hasTable(app.DB(), tableName)
return app.hasTable(app.ConcurrentDB(), tableName)
}
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
// in the current app.AuxDB() instance.
// in the auixiliary.db.
func (app *BaseApp) AuxHasTable(tableName string) bool {
return app.hasTable(app.AuxDB(), tableName)
return app.hasTable(app.AuxConcurrentDB(), tableName)
}
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
var exists bool
var exists int
err := db.Select("(1)").
From("sqlite_schema").
@@ -117,19 +117,17 @@ 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
// in order to reclaim unused data db disk space.
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
func (app *BaseApp) Vacuum() error {
return app.vacuum(app.DB())
return app.vacuum(app.NonconcurrentDB())
}
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
// in order to reclaim unused auxiliary db disk space.
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
func (app *BaseApp) AuxVacuum() error {
return app.vacuum(app.AuxDB())
return app.vacuum(app.AuxNonconcurrentDB())
}
func (app *BaseApp) vacuum(db dbx.Builder) error {
+4 -4
View File
@@ -202,10 +202,10 @@ func TestVacuum(t *testing.T) {
defer app.Cleanup()
calledQueries := []string{}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
@@ -229,10 +229,10 @@ func TestAuxVacuum(t *testing.T) {
defer app.Cleanup()
calledQueries := []string{}
app.AuxDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.AuxDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
+4 -4
View File
@@ -38,12 +38,12 @@ func TestModelQuery(t *testing.T) {
modelsQuery := app.ModelQuery(&core.Collection{})
logsModelQuery := app.AuxModelQuery(&core.Collection{})
if app.DB() == modelsQuery.Info().Builder {
t.Fatalf("ModelQuery() is not using app.DB()")
if app.ConcurrentDB() == modelsQuery.Info().Builder {
t.Fatalf("ModelQuery() is not using app.ConcurrentDB()")
}
if app.AuxDB() == logsModelQuery.Info().Builder {
t.Fatalf("AuxModelQuery() is not using app.AuxDB()")
if app.AuxConcurrentDB() == logsModelQuery.Info().Builder {
t.Fatalf("AuxModelQuery() is not using app.AuxConcurrentDB()")
}
expectedSQL := "SELECT {{_collections}}.* FROM `_collections`"
+12 -5
View File
@@ -60,7 +60,7 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
clone.nonconcurrentDB = tx
}
clone.txInfo = &txAppInfo{
clone.txInfo = &TxAppInfo{
parent: app,
isForAuxDB: isForAuxDB,
}
@@ -68,22 +68,29 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
return &clone
}
type txAppInfo struct {
// TxAppInfo represents an active transaction context associated to an existing app instance.
type TxAppInfo struct {
parent *BaseApp
afterFuncs []func(txErr error) error
mu sync.Mutex
isForAuxDB bool
}
func (a *txAppInfo) onAfterFunc(fn func(txErr error) error) {
// OnComplete registers the provided callback that will be invoked
// once the related transaction ends (either completes successfully or rollbacked with an error).
//
// The callback receives the transaction error (if any) as its argument.
// Any additional errors returned by the OnComplete callbacks will be
// joined together with txErr when returning the final transaction result.
func (a *TxAppInfo) OnComplete(fn func(txErr error) error) {
a.mu.Lock()
defer a.mu.Unlock()
a.afterFuncs = append(a.afterFuncs, fn)
}
// note: can be called only once because txAppInfo is cleared
func (a *txAppInfo) runAfterFuncs(txErr error) error {
// note: can be called only once because TxAppInfo is cleared
func (a *TxAppInfo) runAfterFuncs(txErr error) error {
a.mu.Lock()
defer a.mu.Unlock()
+3 -1
View File
@@ -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
+2 -2
View File
@@ -277,7 +277,7 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
me.Error = re.Error
re.Error = me.Error
}
// -------------------------------------------------------------------
@@ -354,7 +354,7 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
me.Error = ce.Error
ce.Error = me.Error
}
// -------------------------------------------------------------------
+1 -1
View File
@@ -155,7 +155,7 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.DB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.ConcurrentDB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
result, err := app.FindFirstExternalAuthByExpr(s.expr)
hasErr := err != nil
+8 -3
View File
@@ -5,11 +5,11 @@ import (
"database/sql/driver"
"errors"
"fmt"
"log"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
@@ -356,8 +356,7 @@ func (f *FileField) Intercept(
return nil
case InterceptorActionAfterCreateError, InterceptorActionAfterUpdateError:
// when in transaction we assume that the error was handled by afterRecordExecuteFailure
_, insideTransaction := app.DB().(*dbx.Tx)
if insideTransaction {
if app.IsTransactional() {
return actionFunc()
}
@@ -637,7 +636,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)))
}
+2 -2
View File
@@ -672,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}]`,
},
+148
View File
@@ -0,0 +1,148 @@
package core
import (
"context"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/types"
)
func init() {
Fields[FieldTypeGeoPoint] = func() Field {
return &GeoPointField{}
}
}
const FieldTypeGeoPoint = "geoPoint"
var (
_ Field = (*GeoPointField)(nil)
)
// GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates.
//
// You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props.
// The stored value is always converted to [types.GeoPoint].
// Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint].
//
// Examples of updating a record's GeoPointField value programmatically:
//
// record.Set("location", types.GeoPoint{Lat: 123, Lon: 456})
// record.Set("location", map[string]any{"lat":123, "lon":456})
// record.Set("location", []byte(`{"lat":123, "lon":456}`)
type GeoPointField struct {
// Name (required) is the unique name of the field.
Name string `form:"name" json:"name"`
// Id is the unique stable field identifier.
//
// It is automatically generated from the name when adding to a collection FieldsList.
Id string `form:"id" json:"id"`
// System prevents the renaming and removal of the field.
System bool `form:"system" json:"system"`
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Required will require the field coordinates to be non-zero (aka. not "Null Island").
Required bool `form:"required" json:"required"`
}
// Type implements [Field.Type] interface method.
func (f *GeoPointField) Type() string {
return FieldTypeGeoPoint
}
// GetId implements [Field.GetId] interface method.
func (f *GeoPointField) GetId() string {
return f.Id
}
// SetId implements [Field.SetId] interface method.
func (f *GeoPointField) SetId(id string) {
f.Id = id
}
// GetName implements [Field.GetName] interface method.
func (f *GeoPointField) GetName() string {
return f.Name
}
// SetName implements [Field.SetName] interface method.
func (f *GeoPointField) SetName(name string) {
f.Name = name
}
// GetSystem implements [Field.GetSystem] interface method.
func (f *GeoPointField) GetSystem() bool {
return f.System
}
// SetSystem implements [Field.SetSystem] interface method.
func (f *GeoPointField) SetSystem(system bool) {
f.System = system
}
// GetHidden implements [Field.GetHidden] interface method.
func (f *GeoPointField) GetHidden() bool {
return f.Hidden
}
// SetHidden implements [Field.SetHidden] interface method.
func (f *GeoPointField) SetHidden(hidden bool) {
f.Hidden = hidden
}
// ColumnType implements [Field.ColumnType] interface method.
func (f *GeoPointField) ColumnType(app App) string {
return `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
}
// PrepareValue implements [Field.PrepareValue] interface method.
func (f *GeoPointField) PrepareValue(record *Record, raw any) (any, error) {
point := types.GeoPoint{}
err := point.Scan(raw)
return point, err
}
// ValidateValue implements [Field.ValidateValue] interface method.
func (f *GeoPointField) ValidateValue(ctx context.Context, app App, record *Record) error {
val, ok := record.GetRaw(f.Name).(types.GeoPoint)
if !ok {
return validators.ErrUnsupportedValueType
}
// zero value
if val.Lat == 0 && val.Lon == 0 {
if f.Required {
return validation.ErrRequired
}
return nil
}
if val.Lat < -90 || val.Lat > 90 {
return validation.NewError("validation_invalid_latitude", "Latitude must be between -90 and 90 degrees.")
}
if val.Lon < -180 || val.Lon > 180 {
return validation.NewError("validation_invalid_longitude", "Longitude must be between -180 and 180 degrees.")
}
return nil
}
// ValidateSettings implements [Field.ValidateSettings] interface method.
func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
)
}
+202
View File
@@ -0,0 +1,202 @@
package core_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestGeoPointFieldBaseMethods(t *testing.T) {
testFieldBaseMethods(t, core.FieldTypeGeoPoint)
}
func TestGeoPointFieldColumnType(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
f := &core.GeoPointField{}
expected := `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
if v := f.ColumnType(app); v != expected {
t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
}
}
func TestGeoPointFieldPrepareValue(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
f := &core.GeoPointField{}
record := core.NewRecord(core.NewBaseCollection("test"))
scenarios := []struct {
raw any
expected string
}{
{nil, `{"lon":0,"lat":0}`},
{"", `{"lon":0,"lat":0}`},
{[]byte{}, `{"lon":0,"lat":0}`},
{map[string]any{}, `{"lon":0,"lat":0}`},
{types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
{&types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
{[]byte(`{"lon": 10, "lat": 20}`), `{"lon":10,"lat":20}`},
{map[string]any{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
{map[string]float64{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
v, err := f.PrepareValue(record, s.raw)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
if rawStr != s.expected {
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, rawStr)
}
})
}
}
func TestGeoPointFieldValidateValue(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewBaseCollection("test_collection")
scenarios := []struct {
name string
field *core.GeoPointField
record func() *core.Record
expectError bool
}{
{
"invalid raw value",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", 123)
return record
},
true,
},
{
"zero field value (non-required)",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{})
return record
},
false,
},
{
"zero field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{})
return record
},
true,
},
{
"non-zero Lat field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: 1})
return record
},
false,
},
{
"non-zero Lon field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: 1})
return record
},
false,
},
{
"non-zero Lat-Lon field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: -1, Lat: -2})
return record
},
false,
},
{
"lat < -90",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: -90.1})
return record
},
true,
},
{
"lat > 90",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: 90.1})
return record
},
true,
},
{
"lon < -180",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: -180.1})
return record
},
true,
},
{
"lon > 180",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: 180.1})
return record
},
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
err := s.field.ValidateValue(context.Background(), app, s.record())
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}
func TestGeoPointFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
}
+2 -2
View File
@@ -20,7 +20,7 @@ func init() {
const FieldTypeJSON = "json"
const DefaultJSONFieldMaxSize int64 = 5 << 20
const DefaultJSONFieldMaxSize int64 = 1 << 20
var (
_ Field = (*JSONField)(nil)
@@ -53,7 +53,7 @@ type JSONField struct {
// 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.
// If zero, a default limit of 1MB is applied.
MaxSize int64 `form:"maxSize" json:"maxSize"`
// Required will require the field value to be non-empty JSON value
+1 -1
View File
@@ -146,7 +146,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
&core.JSONField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (5<<20))+`"`))
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (1<<20))+`"`))
return record
},
true,
+1 -1
View File
@@ -219,7 +219,7 @@ func (f *RelationField) ValidateValue(ctx context.Context, app App, record *Reco
}
var total int
_ = app.DB().
_ = app.ConcurrentDB().
Select("count(*)").
From(relCollection.Name).
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
+4 -4
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
err := app.DB().
var exists int
err := app.ConcurrentDB().
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.")
}
}
+4 -4
View File
@@ -55,10 +55,10 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
}
// silence query logs
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.concurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.concurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.nonconcurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.nonconcurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.Settings().Logs.MinLevel = testLogLevel
if err := app.Save(app.Settings()); err != nil {
+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.
+14 -18
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
@@ -116,7 +112,7 @@ func (r *MigrationsRunner) Run(args ...string) error {
color.Green("The %s table was synced with the available migrations.", r.tableName)
return nil
default:
return fmt.Errorf("Unsupported command: %q\n", cmd)
return fmt.Errorf("unsupported command: %q", cmd)
}
}
@@ -155,12 +151,12 @@ func (r *MigrationsRunner) Up() ([]string, error) {
// ignore empty Up action
if m.Up != nil {
if err := m.Up(txApp); err != nil {
return fmt.Errorf("Failed to apply migration %s: %w", m.File, err)
return fmt.Errorf("failed to apply migration %s: %w", m.File, err)
}
}
if err := r.saveAppliedMigration(txApp, m.File); err != nil {
return fmt.Errorf("Failed to save applied migration info for %s: %w", m.File, err)
return fmt.Errorf("failed to save applied migration info for %s: %w", m.File, err)
}
applied = append(applied, m.File)
@@ -208,12 +204,12 @@ func (r *MigrationsRunner) Down(toRevertCount int) ([]string, error) {
// ignore empty Down action
if m.Down != nil {
if err := m.Down(txApp); err != nil {
return fmt.Errorf("Failed to revert migration %s: %w", m.File, err)
return fmt.Errorf("failed to revert migration %s: %w", m.File, err)
}
}
if err := r.saveRevertedMigration(txApp, m.File); err != nil {
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.File, err)
return fmt.Errorf("failed to save reverted migration info for %s: %w", m.File, err)
}
reverted = append(reverted, m.File)
@@ -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
// }
+10
View File
@@ -11,6 +11,7 @@ import (
"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"
)
@@ -300,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:
@@ -338,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)
}
+14 -4
View File
@@ -419,8 +419,9 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
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 {
// json or geoPoint field -> treat the rest of the props as json path
// @todo consider converting to "JSONExtractable" interface with optional extra validation for the remaining props?
if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
var jsonPath strings.Builder
for j, p := range r.activeProps[i+1:] {
if _, err := strconv.Atoi(p); err == nil {
@@ -479,7 +480,11 @@ 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])
}
@@ -492,7 +497,11 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
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
@@ -505,7 +514,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 {
File diff suppressed because one or more lines are too long
+19 -5
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"slices"
"sort"
@@ -968,25 +969,32 @@ func (m *Record) GetDateTime(key string) types.DateTime {
return d
}
// GetGeoPoint returns the data value for "key" as a GeoPoint instance.
func (m *Record) GetGeoPoint(key string) types.GeoPoint {
point := types.GeoPoint{}
_ = point.Scan(m.Get(key))
return point
}
// GetStringSlice returns the data value for "key" as a slice of non-zero unique strings.
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)
@@ -994,6 +1002,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
+327 -5
View File
@@ -5,9 +5,11 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
@@ -16,6 +18,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -1013,7 +1016,44 @@ func TestRecordGetStringSlice(t *testing.T) {
}
}
func TestRecordGetUploadedFiles(t *testing.T) {
func TestRecordGetGeoPoint(t *testing.T) {
t.Parallel()
scenarios := []struct {
value any
expected string
}{
{nil, `{"lon":0,"lat":0}`},
{"", `{"lon":0,"lat":0}`},
{0, `{"lon":0,"lat":0}`},
{false, `{"lon":0,"lat":0}`},
{"{}", `{"lon":0,"lat":0}`},
{"[]", `{"lon":0,"lat":0}`},
{[]int{1, 2}, `{"lon":0,"lat":0}`},
{map[string]any{"lon": 1, "lat": 2}, `{"lon":1,"lat":2}`},
{[]byte(`{"lon":1,"lat":2}`), `{"lon":1,"lat":2}`},
{`{"lon":1,"lat":2}`, `{"lon":1,"lat":2}`},
{types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
{&types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
}
collection := core.NewBaseCollection("test")
record := core.NewRecord(collection)
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
record.Set("test", s.value)
pointStr := record.GetGeoPoint("test").String()
if pointStr != s.expected {
t.Fatalf("Expected %q, got %q", s.expected, pointStr)
}
})
}
}
func TestRecordGetUnsavedFiles(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
@@ -1054,14 +1094,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 {
@@ -1618,6 +1658,288 @@ func TestRecordValidate(t *testing.T) {
})
}
func TestRecordModelEventSync(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
testRecords := make([]*core.Record, 4)
for i := 0; i < 4; i++ {
testRecords[i] = core.NewRecord(col)
testRecords[i].Set("title", "sync_test_"+strconv.Itoa(i))
if err := app.Save(testRecords[i]); err != nil {
t.Fatal(err)
}
}
createModelEvent := func() *core.ModelEvent {
event := new(core.ModelEvent)
event.App = app
event.Context = context.Background()
event.Type = "test_a"
event.Model = testRecords[0]
return event
}
createModelErrorEvent := func() *core.ModelErrorEvent {
event := new(core.ModelErrorEvent)
event.ModelEvent = *createModelEvent()
event.Error = errors.New("error_a")
return event
}
changeRecordEventBefore := func(e *core.RecordEvent) {
e.Type = "test_b"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 123)
e.Record = testRecords[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testRecords[2]
}
changeRecordEventAfter := func(e *core.RecordEvent) {
e.Type = "test_d"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 789)
e.Record = testRecords[3]
}
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Model.PK() != testRecords[0].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Model.PK(), 0)
}
}
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_d" {
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
}
if v := e.Context.Value("test"); v != 789 {
t.Fatalf("Expected context value %v, got %v", 789, v)
}
// note: currently the Model and Record values are not synced due to performance consideration
if e.Model.PK() != testRecords[2].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[2].Id, e.Model.PK(), 2)
}
}
expectedBeforeRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Record.Id != testRecords[0].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Record.Id, 2)
}
}
expectedAfterRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
if e.Type != "test_c" {
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
}
if v := e.Context.Value("test"); v != 456 {
t.Fatalf("Expected context value %v, got %v", 456, v)
}
// note: currently the Model and Record values are not synced due to performance consideration
if e.Record.Id != testRecords[1].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[1].Id, e.Record.Id, 1)
}
}
modelEventFinalizer := func(e *core.ModelEvent) error {
modelEventFinalizerChange(e)
return nil
}
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
modelEventFinalizerChange(&e.ModelEvent)
e.Error = errors.New("error_c")
return nil
}
modelEventHandler := &hook.Handler[*core.ModelEvent]{
Priority: -999,
Func: func(e *core.ModelEvent) error {
t.Run("before model", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, e)
})
_ = e.Next()
t.Run("after model", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, e)
})
return nil
},
}
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
Priority: -999,
Func: func(e *core.ModelErrorEvent) error {
t.Run("before model error", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_a", v)
}
})
_ = e.Next()
t.Run("after model error", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_d" {
t.Fatalf("Expected error %q, got %q", "error_d", v)
}
})
return nil
},
}
recordEventHandler := &hook.Handler[*core.RecordEvent]{
Priority: -999,
Func: func(e *core.RecordEvent) error {
t.Run("before record", func(t *testing.T) {
expectedBeforeRecordEventHandlerChecks(t, e)
})
changeRecordEventBefore(e)
_ = e.Next()
t.Run("after record", func(t *testing.T) {
expectedAfterRecordEventHandlerChecks(t, e)
})
changeRecordEventAfter(e)
return nil
},
}
recordErrorEventHandler := &hook.Handler[*core.RecordErrorEvent]{
Priority: -999,
Func: func(e *core.RecordErrorEvent) error {
t.Run("before record error", func(t *testing.T) {
expectedBeforeRecordEventHandlerChecks(t, &e.RecordEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeRecordEventBefore(&e.RecordEvent)
e.Error = errors.New("error_b")
_ = e.Next()
t.Run("after record error", func(t *testing.T) {
expectedAfterRecordEventHandlerChecks(t, &e.RecordEvent)
if v := e.Error.Error(); v != "error_c" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeRecordEventAfter(&e.RecordEvent)
e.Error = errors.New("error_d")
return nil
},
}
// OnModelValidate
app.OnRecordValidate().Bind(recordEventHandler)
app.OnModelValidate().Bind(modelEventHandler)
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreate
app.OnRecordCreate().Bind(recordEventHandler)
app.OnModelCreate().Bind(modelEventHandler)
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreateExecute
app.OnRecordCreateExecute().Bind(recordEventHandler)
app.OnModelCreateExecute().Bind(modelEventHandler)
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateSuccess
app.OnRecordAfterCreateSuccess().Bind(recordEventHandler)
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateError
app.OnRecordAfterCreateError().Bind(recordErrorEventHandler)
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelUpdate
app.OnRecordUpdate().Bind(recordEventHandler)
app.OnModelUpdate().Bind(modelEventHandler)
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelUpdateExecute
app.OnRecordUpdateExecute().Bind(recordEventHandler)
app.OnModelUpdateExecute().Bind(modelEventHandler)
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateSuccess
app.OnRecordAfterUpdateSuccess().Bind(recordEventHandler)
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateError
app.OnRecordAfterUpdateError().Bind(recordErrorEventHandler)
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelDelete
app.OnRecordDelete().Bind(recordEventHandler)
app.OnModelDelete().Bind(modelEventHandler)
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelDeleteExecute
app.OnRecordDeleteExecute().Bind(recordEventHandler)
app.OnModelDeleteExecute().Bind(modelEventHandler)
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteSuccess
app.OnRecordAfterDeleteSuccess().Bind(recordEventHandler)
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteError
app.OnRecordAfterDeleteError().Bind(recordErrorEventHandler)
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
}
func TestRecordSave(t *testing.T) {
t.Parallel()
@@ -1932,13 +2254,13 @@ func TestRecordDelete(t *testing.T) {
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
rec3, _ := app.FindRecordById("users", "oap640cot4yru2s")
+19 -4
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"
@@ -34,7 +35,7 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
tableName = "@@__invalidCollectionModelOrIdentifier"
}
query := app.DB().Select(app.DB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
query := app.ConcurrentDB().Select(app.ConcurrentDB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
// in case of an error attach a new context and cancel it immediately with the error
if collectionErr != nil {
@@ -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
}
+2 -2
View File
@@ -103,7 +103,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
// add the related id(s) as a dynamic relation field value to
// allow further expand checks at later stage in a more unified manner
prepErr := func() error {
q := app.DB().Select("id").
q := app.ConcurrentDB().Select("id").
From(indirectRel.Name).
Limit(1000) // the limit is arbitrary chosen and may change in the future
@@ -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"
)
+3 -2
View File
@@ -64,8 +64,9 @@ func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []stri
normalizedErrs := validation.Errors{}
for _, name := range fieldNames {
// note: extra space to exclude other fields starting with the current field name
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")
}
}
+7
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"),
+2 -2
View File
@@ -440,7 +440,7 @@ func getQueryTableInfo(app App, selectQuery string) ([]*TableInfoRow, error) {
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]+\*\/)|(--.+$)`)
commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]*?\*\/)|(--.+$)`)
)
type identifier struct {
@@ -455,7 +455,7 @@ type identifiersParser struct {
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = commentsReplaceRegex.ReplaceAllString(str, "")
str = commentsReplaceRegex.ReplaceAllString(str, " ")
str = joinReplaceRegex.ReplaceAllString(str, " __pb_join__ ")
str = discardReplaceRegex.ReplaceAllString(str, " __pb_discard__ ")
+2 -2
View File
@@ -249,8 +249,8 @@ func TestCreateViewFields(t *testing.T) {
demo1.id,
demo1.text,
/* multi
line comment */
demo1.url, demo1.created, demo2.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
+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"
)
+35 -8
View File
@@ -27,9 +27,10 @@ type RecordUpsert struct {
accessLevel int
// extra password fields
password string
passwordConfirm string
oldPassword string
disablePasswordValidations bool
password string
passwordConfirm string
oldPassword string
}
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
@@ -130,6 +131,8 @@ func (form *RecordUpsert) validateFormFields() error {
return nil
}
form.syncPasswordFields()
isNew := form.record.IsNew()
original := form.record.Original()
@@ -165,17 +168,17 @@ func (form *RecordUpsert) validateFormFields() error {
validation.Key(
"password",
validation.When(
(isNew || form.passwordConfirm != "" || form.oldPassword != ""),
!form.disablePasswordValidations && (isNew || form.passwordConfirm != "" || form.oldPassword != ""),
validation.Required,
),
),
validation.Key(
"passwordConfirm",
validation.When(
(isNew || form.password != "" || form.oldPassword != ""),
!form.disablePasswordValidations && (isNew || form.password != "" || form.oldPassword != ""),
validation.Required,
),
validation.By(validators.Equal(form.password)),
validation.When(!form.disablePasswordValidations, validation.By(validators.Equal(form.password))),
),
validation.Key(
"oldPassword",
@@ -183,7 +186,7 @@ func (form *RecordUpsert) validateFormFields() error {
// - form.HasManageAccess() is not satisfied
// - changing the existing password
validation.When(
!isNew && !form.HasManageAccess() && (form.password != "" || form.passwordConfirm != ""),
!form.disablePasswordValidations && !isNew && !form.HasManageAccess() && (form.password != "" || form.passwordConfirm != ""),
validation.Required,
validation.By(form.checkOldPassword),
),
@@ -224,7 +227,7 @@ func (form *RecordUpsert) DrySubmit(callback func(txApp core.App, drySavedRecord
app := form.app.UnsafeWithoutHooks()
_, isTransactional := app.DB().(*dbx.Tx)
isTransactional := app.IsTransactional()
if !isTransactional {
return app.RunInTransaction(func(txApp core.App) error {
tx, ok := txApp.DB().(*dbx.Tx)
@@ -287,3 +290,27 @@ func (form *RecordUpsert) Submit() error {
// run record validations and persist in db
return form.app.SaveWithContext(form.ctx, form.record)
}
// syncPasswordFields syncs the form's auth password fields with their
// corresponding record field values.
//
// This could be useful in case the password fields were programmatically set
// directly by modifying the related record model.
func (form *RecordUpsert) syncPasswordFields() {
if !form.record.Collection().IsAuth() {
return // not an auth collection
}
form.disablePasswordValidations = false
rawPassword := form.record.GetRaw(core.FieldNamePassword)
if v, ok := rawPassword.(*core.PasswordFieldValue); ok && v != nil {
if
// programmatically set custom plain password value
(v.Plain != "" && v.Plain != form.password) ||
// random generated password for new record
(v.Plain == "" && v.Hash != "" && form.record.IsNew()) {
form.disablePasswordValidations = true
}
}
}
+113
View File
@@ -887,6 +887,119 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
testFilesCount(t, testApp, record, 2) // the file + attrs
}
func TestRecordUpsertPasswordsSync(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
users, err := testApp.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
t.Run("new user without password", func(t *testing.T) {
record := core.NewRecord(users)
form := forms.NewRecordUpsert(testApp, record)
err := form.Submit()
tests.TestValidationErrors(t, err, []string{"password", "passwordConfirm"})
})
t.Run("new user with manual password", func(t *testing.T) {
record := core.NewRecord(users)
form := forms.NewRecordUpsert(testApp, record)
record.SetPassword("1234567890")
err := form.Submit()
if err != nil {
t.Fatalf("Expected no errors, got %v", err)
}
})
t.Run("new user with random password", func(t *testing.T) {
record := core.NewRecord(users)
form := forms.NewRecordUpsert(testApp, record)
record.SetRandomPassword()
err := form.Submit()
if err != nil {
t.Fatalf("Expected no errors, got %v", err)
}
})
t.Run("update user with no password change", func(t *testing.T) {
record, err := testApp.FindAuthRecordByEmail(users, "test@example.com")
if err != nil {
t.Fatal(err)
}
oldHash := record.GetString("password:hash")
form := forms.NewRecordUpsert(testApp, record)
err = form.Submit()
if err != nil {
t.Fatalf("Expected no errors, got %v", err)
}
newHash := record.GetString("password:hash")
if newHash == "" || newHash != oldHash {
t.Fatal("Expected no password change")
}
})
t.Run("update user with manual password change", func(t *testing.T) {
record, err := testApp.FindAuthRecordByEmail(users, "test@example.com")
if err != nil {
t.Fatal(err)
}
oldHash := record.GetString("password:hash")
form := forms.NewRecordUpsert(testApp, record)
record.SetPassword("1234567890")
err = form.Submit()
if err != nil {
t.Fatalf("Expected no errors, got %v", err)
}
newHash := record.GetString("password:hash")
if newHash == "" || newHash == oldHash {
t.Fatal("Expected password change")
}
})
t.Run("update user with random password change", func(t *testing.T) {
record, err := testApp.FindAuthRecordByEmail(users, "test@example.com")
if err != nil {
t.Fatal(err)
}
oldHash := record.GetString("password:hash")
form := forms.NewRecordUpsert(testApp, record)
record.SetRandomPassword()
err = form.Submit()
if err != nil {
t.Fatalf("Expected no errors, got %v", err)
}
newHash := record.GetString("password:hash")
if newHash == "" || newHash == oldHash {
t.Fatal("Expected password change")
}
})
}
// -------------------------------------------------------------------
func testFilesCount(t *testing.T, app core.App, record *core.Record, count int) {
+24 -60
View File
@@ -1,86 +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.8
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
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.8
github.com/ganigeorgiev/fexpr v0.4.1
github.com/gabriel-vasile/mimetype v1.4.9
github.com/ganigeorgiev/fexpr v0.5.0
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-20250103200817-ca580d8c5119
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.8.1
gocloud.dev v0.40.0
golang.org/x/crypto v0.32.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/sync v0.10.0
modernc.org/sqlite v1.34.4
github.com/spf13/cast v1.8.0
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.38.0
golang.org/x/image v0.27.0
golang.org/x/net v0.40.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.14.0
modernc.org/sqlite v1.37.0
)
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.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 // 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.27 // 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.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 // 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-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // 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.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/image v0.23.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.29.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.216.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 // indirect
google.golang.org/grpc v1.69.2 // indirect
google.golang.org/protobuf v1.36.2 // indirect
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6 // indirect
modernc.org/libc v1.55.3 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.1 // indirect
modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect
modernc.org/memory v1.9.1 // indirect
)
+61 -291
View File
@@ -1,181 +1,60 @@
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.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
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.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
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.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo=
github.com/aws/aws-sdk-go-v2 v1.32.8/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.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg=
github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk=
github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48 h1:XnXVe2zRyPf0+fAW5L05esmngvBpC6DQZK7oZB/z/Co=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48/go.mod h1:S3wey90OrS4f7kYxH6PT175YyEcHTORY07++HurMaRM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8=
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.27 h1:AmB5QxnD+fBFrg9LcqzkgF/CaYvMyU/BTlejG4t1S7Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.27/go.mod h1:Sai7P3xTiyv9ZUYO3IFxMnmiIP759/67iQbU4kdmkyU=
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.8 h1:iwYS40JnrBeA9e9aI5S6KKN4EB2zR4iUVYN0nwVivz4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.8/go.mod h1:Fm9Mi+ApqmFiknZtGpohVcBGvpTu542VC4XO9YudRi0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.8 h1:/Mn7gTedG86nbpjT4QEKsN1D/fThiYe1qvq7WsBGNHg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.8/go.mod h1:Ae3va9LPmvjj231ukHB6UeT8nS7wTPfC3tMZSZMwNYg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2 h1:a7aQ3RW+ug4IbhoQp29NZdc7vqrzKZZfWZSaQAXOZvQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2/go.mod h1:xMekrnhmJ5aqmyxtmALs7mlvXw5xRh+eYjOjvrIIFJ4=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dvu921NpGk7y43tAM=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
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.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/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
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=
@@ -184,184 +63,75 @@ 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-20250103200817-ca580d8c5119 h1:TjQtEReJDTpvlNFTRjuHvPQpJHAeJdcQF130eCAAT/o=
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
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.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/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.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.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.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.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.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
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.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
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.216.0 h1:xnEHy+xWFrtYInWPy8OdGFsyIfWJjtVnO39g7pz2BFY=
google.golang.org/api v0.216.0/go.mod h1:K9wzQMvWi47Z9IU7OgdOofvZuw75Ge3PPITImZR/UyI=
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.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
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-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 h1:3UsHvIr4Wc2aW4brOaSCmcxh9ksica6fHEr8P1XhkYw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
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.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.69.2/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.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
google.golang.org/protobuf v1.36.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.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
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.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6 h1:JoKwHjIFumiKrjMbp1cNbC5E9UyCgA/ZcID0xOWQ2N8=
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6/go.mod h1:LG5UO1Ran4OO0JRKz2oNiXhR5nNrgz0PzH7UKhz0aMU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
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.1 h1:HS1HRg1jEohnuONobEq2WrLEhLyw8+J42yLFTnllm2A=
modernc.org/memory v1.8.1/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
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.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
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=
+6 -7
View File
@@ -1,28 +1,27 @@
version: "2"
run:
go: 1.23
concurrency: 4
timeout: 10m
linters:
disable-all: true
default: none
enable:
- asasalint
- asciicheck
- gofmt
- goimports
- gomodguard
- goprintffuncname
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- prealloc
- reassign
- staticcheck
- typecheck
- unconvert
- unused
- whitespace
formatters:
enable:
- gofmt
- goimports
+2 -2
View File
@@ -10,8 +10,8 @@ import (
)
const (
expectedDriverVersion = "v1.34.4"
expectedLibcVersion = "v1.55.3"
expectedDriverVersion = "v1.37.0"
expectedLibcVersion = "v1.62.1"
// 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.
+5 -9
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
@@ -208,13 +204,13 @@ func (p *plugin) update(withBackup bool) error {
// try again with an .exe extension
newExec = newExec + ".exe"
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
}
}
// rename the current executable
if err := os.Rename(oldExec, renamedOldExec); err != nil {
return fmt.Errorf("Failed to rename the current executable: %w", err)
return fmt.Errorf("failed to rename the current executable: %w", err)
}
tryToRevertExecChanges := func() {
@@ -231,7 +227,7 @@ func (p *plugin) update(withBackup bool) error {
// replace with the extracted binary
if err := os.Rename(newExec, oldExec); err != nil {
tryToRevertExecChanges()
return fmt.Errorf("Failed replacing the executable: %w", err)
return fmt.Errorf("failed replacing the executable: %w", err)
}
if withBackup {
+146 -90
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"
@@ -58,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 {
@@ -82,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)
@@ -102,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 {
@@ -122,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)
}
}
@@ -189,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 {
@@ -198,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)
@@ -243,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,
@@ -255,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)
@@ -267,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 {
@@ -277,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)
@@ -503,6 +495,10 @@ func baseBinds(vm *goja.Runtime) {
instance := &core.FileField{}
return structConstructorUnmarshal(vm, call, instance)
})
vm.Set("GeoPointField", func(call goja.ConstructorCall) *goja.Object {
instance := &core.GeoPointField{}
return structConstructorUnmarshal(vm, call, instance)
})
// ---
vm.Set("MailerMessage", func(call goja.ConstructorCall) *goja.Object {
@@ -538,12 +534,36 @@ 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()
val, _ := call.Argument(0).Export().(string)
if val != "" {
instance, _ = types.ParseDateTime(val)
rawDate, _ := call.Argument(0).Export().(string)
locName, _ := call.Argument(1).Export().(string)
if rawDate != "" && locName != "" {
loc, err := time.LoadLocation(locName)
if err != nil {
loc = time.UTC
}
instance, _ = types.ParseDateTime(cast.ToTimeInDefaultLocation(rawDate, loc))
} else if rawDate != "" {
// forward directly to ParseDateTime to preserve the original behavior
instance, _ = types.ParseDateTime(rawDate)
}
instanceValue := vm.ToValue(instance).(*goja.Object)
@@ -700,6 +720,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)
@@ -766,11 +787,15 @@ func httpClientBinds(vm *goja.Runtime) {
})
type sendResult struct {
JSON any `json:"json"`
Headers map[string][]string `json:"headers"`
Cookies map[string]*http.Cookie `json:"cookies"`
Raw string `json:"raw"`
StatusCode int `json:"statusCode"`
JSON any `json:"json"`
Headers map[string][]string `json:"headers"`
Cookies map[string]*http.Cookie `json:"cookies"`
// Deprecated: consider using Body instead
Raw string `json:"raw"`
Body []byte `json:"body"`
StatusCode int `json:"statusCode"`
}
type sendConfig struct {
@@ -875,6 +900,7 @@ func httpClientBinds(vm *goja.Runtime) {
Headers: map[string][]string{},
Cookies: map[string]*http.Cookie{},
Raw: string(bodyRaw),
Body: bodyRaw,
}
for k, v := range res.Header {
@@ -885,7 +911,7 @@ func httpClientBinds(vm *goja.Runtime) {
result.Cookies[v.Name] = v
}
if len(result.Raw) != 0 {
if len(result.Body) > 0 {
// try as map
result.JSON = map[string]any{}
if err := json.Unmarshal(bodyRaw, &result.JSON); err != nil {
@@ -903,6 +929,29 @@ 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.
//
@@ -1004,11 +1053,19 @@ func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, ins
return instanceValue
}
var cachedDynamicModels = store.New[string, *dynamicModelType](nil)
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{
@@ -1016,46 +1073,21 @@ var cachedDynamicModels = store.New[string, *dynamicModelType](nil)
// "total": 0,
// })
func newDynamicModel(shape map[string]any) any {
var modelType *dynamicModelType
info := make([]*shapeFieldInfo, 0, len(shape))
shapeRaw, err := json.Marshal(shape)
if err != nil {
modelType = getDynamicModelStruct(shape)
} else {
modelType = cachedDynamicModels.GetOrSet(string(shapeRaw), func() *dynamicModelType {
return getDynamicModelStruct(shape)
})
var hash strings.Builder
sortedKeys := make([]string, 0, len(shape))
for k := range shape {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
rvShapeValues := make([]reflect.Value, len(modelType.shapeValues))
for i, v := range modelType.shapeValues {
rvShapeValues[i] = reflect.ValueOf(v)
}
elem := reflect.New(modelType.structType).Elem()
for i, v := range rvShapeValues {
elem.Field(i).Set(v)
}
return elem.Addr().Interface()
}
type dynamicModelType struct {
structType reflect.Type
shapeValues []any
}
func getDynamicModelStruct(shape map[string]any) *dynamicModelType {
result := new(dynamicModelType)
result.shapeValues = make([]any, 0, len(shape))
structFields := make([]reflect.StructField, 0, len(shape))
for k, v := range shape {
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]{}
@@ -1070,16 +1102,40 @@ func getDynamicModelStruct(shape map[string]any) *dynamicModelType {
vt = reflect.TypeOf(newV)
}
result.shapeValues = append(result.shapeValues, 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})
}
result.structType = reflect.StructOf(structFields)
st := cachedDynamicModelStructs.GetOrSet(hash.String(), func() reflect.Type {
structFields := make([]reflect.StructField, len(info))
return result
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()
// load default values into the new model
for i, item := range info {
elem.Field(i).Set(reflect.ValueOf(item.value))
}
return elem.Addr().Interface()
}
type shapeFieldInfo struct {
value any
valueType reflect.Type
key string
}
+178 -26
View File
@@ -44,7 +44,7 @@ func TestBaseBindsCount(t *testing.T) {
vm := goja.New()
baseBinds(vm)
testBindsCount(vm, "this", 32, t)
testBindsCount(vm, "this", 34, 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 {
@@ -399,6 +399,10 @@ func TestBaseBindsNamedFields(t *testing.T) {
"new FileField({name: 'test'})",
isType[*core.FileField],
},
{
"new GeoPointField({name: 'test'})",
isType[*core.GeoPointField],
},
}
for _, s := range scenarios {
@@ -538,20 +542,65 @@ 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)
_, err := vm.RunString(`
const v0 = new DateTime();
if (v0.isZero()) {
throw new Error('Expected to fallback to now, got zero value');
const now = new DateTime();
if (now.isZero()) {
throw new Error('(now) Expected to fallback to now, got zero value');
}
const v1 = new DateTime('2023-01-01 00:00:00.000Z');
const expected = "2023-01-01 00:00:00.000Z"
if (v1.string() != expected) {
throw new Error('Expected ' + expected + ', got ', v1.string());
const nowPart = now.string().substring(0, 19)
const scenarios = [
// empty datetime string and no custom location
{date: new DateTime(''), expected: nowPart},
// empty datetime string and custom default location (should be ignored)
{date: new DateTime('', 'Asia/Tokyo'), expected: nowPart},
// full datetime string and no custom default location
{date: new DateTime('2023-01-01 00:00:00.000Z'), expected: "2023-01-01 00:00:00.000Z"},
// invalid location (fallback to UTC)
{date: new DateTime('2025-10-26 03:00:00', 'invalid'), expected: "2025-10-26 03:00:00.000Z"},
// CET
{date: new DateTime('2025-10-26 03:00:00', 'Europe/Amsterdam'), expected: "2025-10-26 02:00:00.000Z"},
// CEST
{date: new DateTime('2025-10-26 01:00:00', 'Europe/Amsterdam'), expected: "2025-10-25 23:00:00.000Z"},
// with timezone/offset in the date string (aka. should ignore the custom default location)
{date: new DateTime('2025-10-26 01:00:00 +0200', 'Asia/Tokyo'), expected: "2025-10-25 23:00:00.000Z"},
];
for (let i = 0; i < scenarios.length; i++) {
const s = scenarios[i];
if (!s.date.string().includes(s.expected)) {
throw new Error('(' + i + ') ' + s.date.string() + ' does not contain expected ' + s.expected);
}
}
`)
if err != nil {
@@ -1112,6 +1161,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()
@@ -1279,6 +1387,13 @@ func TestHttpClientBindsSend(t *testing.T) {
headers: {"content-type": "text/plain"}, // should be ignored
})
// raw body response field check
const test4 = $http.send({
method: "post",
url: testURL,
body: 'test',
})
const scenarios = [
[test0, {
"statusCode": "400",
@@ -1311,6 +1426,13 @@ func TestHttpClientBindsSend(t *testing.T) {
"multipart/form-data; boundary="
],
}],
[test4, {
"statusCode": "200",
"headers.X-Custom.0": "custom_header",
"cookies.sessionId.value": "123456",
// {"body":"test","headers":{"accept_encoding":"gzip","content_length":"4","user_agent":"Go-http-client/1.1"},"method":"POST"}
"body": [123,34,98,111,100,121,34,58,34,116,101,115,116,34,44,34,104,101,97,100,101,114,115,34,58,123,34,97,99,99,101,112,116,95,101,110,99,111,100,105,110,103,34,58,34,103,122,105,112,34,44,34,99,111,110,116,101,110,116,95,108,101,110,103,116,104,34,58,34,52,34,44,34,117,115,101,114,95,97,103,101,110,116,34,58,34,71,111,45,104,116,116,112,45,99,108,105,101,110,116,47,49,46,49,34,125,44,34,109,101,116,104,111,100,34,58,34,80,79,83,84,34,125],
}],
]
for (let scenario of scenarios) {
@@ -1324,13 +1446,13 @@ func TestHttpClientBindsSend(t *testing.T) {
// check for partial match(es)
for (let exp of expectation) {
if (!value.includes(exp)) {
throw new Error('Expected ' + key + ' to contain ' + exp + ', got: ' + result.raw);
throw new Error('Expected ' + key + ' to contain ' + exp + ', got: ' + toString(result.body));
}
}
} else {
// check for direct match
if (value != expectation) {
throw new Error('Expected ' + key + ' ' + expectation + ', got: ' + result.raw);
throw new Error('Expected ' + key + ' ' + expectation + ', got: ' + toString(result.body));
}
}
}
@@ -1354,7 +1476,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 +1635,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 +1655,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 +1689,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 +1741,5 @@ func TestOsBindsCount(t *testing.T) {
vm := goja.New()
osBinds(vm)
testBindsCount(vm, "$os", 17, t)
testBindsCount(vm, "$os", 18, t)
}
+4 -2
View File
@@ -126,7 +126,8 @@ func (data FormData) toMultipart() (*bytes.Buffer, *multipart.Writer, error) {
}
defer file.Close()
if _, err := io.Copy(mpw, file); err != nil {
_, err = io.Copy(mpw, file)
if err != nil {
return err
}
@@ -136,7 +137,8 @@ func (data FormData) toMultipart() (*bytes.Buffer, *multipart.Writer, error) {
return nil, nil, err
}
default:
if err := mp.WriteField(k, cast.ToString(v)); err != nil {
err := mp.WriteField(k, cast.ToString(v))
if err != nil {
return nil, nil, err
}
}

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