Compare commits

...
176 Commits
Author SHA1 Message Date
Gani Georgiev 52a53b5b91 updated ui/dist 2025-10-17 10:47:48 +03:00
Gani Georgiev 1137a35ded unify code-editor wrapper fields padding 2025-10-17 10:45:24 +03:00
Gani Georgiev 280005e35c [#7259] fixed json field overflow 2025-10-17 10:27:04 +03:00
Gani Georgiev 6656d9820a [#7223] workaround firefox overflow issue 2025-10-15 20:31:40 +03:00
Gani Georgiev 0d8b426b0c updated modernc.org/sqlite 2025-10-15 20:27:35 +03:00
Gani Georgiev acd12ce9dd [#7256] fixed legacy identitity field priority check when a username is a valid email address 2025-10-15 17:25:51 +03:00
Gani Georgiev 47d3da28d5 updated supported modernc.org build targets list 2025-10-08 20:08:16 +03:00
Gani Georgiev 6f8524961f updated go deps 2025-10-08 19:44:46 +03:00
Gani Georgiev fda6ad8d5d bumped min go action version 2025-10-08 17:55:23 +03:00
Gani Georgiev a8321498fd updated changelog 2025-10-03 09:06:03 +03:00
Gani Georgiev ca4902a808 updated ui/dist 2025-10-03 08:53:37 +03:00
Gani Georgiev 348ccfc580 removed explicit golangci go version and fallback to the one from go.mod 2025-10-02 21:01:30 +03:00
Gani Georgiev 77c05dbd2a support Uploader.MaxConcurrency=1 and updated tests 2025-10-02 20:52:36 +03:00
Gani Georgiev 44289a93a2 disable installer when running tests 2025-10-02 20:51:32 +03:00
Gani GeorgievandLoic B. 6b6d3b36d3 [#7208] exlude lost+found from the backups
Co-authored-by: Loic B. <lbndev@yahoo.fr>
2025-09-28 08:28:28 +03:00
Gani Georgiev e26905f8e2 updated go deps 2025-09-22 23:04:20 +03:00
Gani Georgiev 6ad42bde29 added DefaultClient.Send panic/recover handling as an extra precaution 2025-09-22 22:52:58 +03:00
Gani Georgiev 54fb4293c5 revert sqlite dep update 2025-09-22 21:30:54 +03:00
Gani Georgiev a8dd8be524 updated sqlite dep 2025-09-22 20:44:04 +03:00
Gani Georgiev 76a6b9834b fixed Message.WriteSSE example 2025-09-13 23:46:13 +03:00
Gani Georgiev 68ab174f69 wrap DefaultClient.Send with a single lock/unlock and rename mux to mu for consistency 2025-09-13 23:42:46 +03:00
Gani Georgiev a095549304 bumped action min go version 2025-09-07 08:02:36 +03:00
Gani Georgiev 6af4d88529 fixed active index label match 2025-09-07 08:01:36 +03:00
Gani Georgiev 5c9570c8de updated jsvm types 2025-09-06 21:45:20 +03:00
Gani Georgiev 546ea248df updated changelog 2025-09-06 21:45:04 +03:00
Gani Georgiev eda90d4555 check the default user cachedir if GOCACHE is not explicitly set 2025-09-06 21:20:13 +03:00
Gani Georgiev 40f2ba731c added osutils.IsProbablyGoRun 2025-09-06 19:52:51 +03:00
Gani Georgiev a088cf6379 updated jsvm types 2025-09-06 15:18:48 +03:00
Gani Georgiev 8d3ec418e9 enabled seconds in the datepicker 2025-09-06 15:13:06 +03:00
Gani Georgiev f2056f61bd added os.Root bindings to the JSVM 2025-09-06 14:51:27 +03:00
Gani Georgiev 6a5e449b3c bumped go deps 2025-09-06 14:22:07 +03:00
Gani Georgiev 1359a6f8fd [#7153] eagerly escape the S3 path in accordance with the S3 UriEncode signing rules 2025-09-06 11:59:32 +03:00
Gani Georgiev 28de01a188 updated changelog 2025-09-01 21:11:19 +03:00
Gani Georgiev 9e13418565 retry the random func to minimize tests flakiness 2025-08-31 23:35:01 +03:00
Gani Georgiev 172b1f96f7 [#7123] updated exp of test valid jwt tokens 2025-08-31 23:14:55 +03:00
Gani Georgiev 41cc4fd36b increased slightly the wait time to minimize tests flakiness 2025-08-31 20:31:45 +03:00
Gani Georgievandmashizora 45af9e201c [#7130] added Lark OAuth2 provider
Co-authored-by: mashizora <30516315+mashizora@users.noreply.github.com>
2025-08-30 12:57:14 +03:00
Gani Georgiev cc902f2df8 updated scaffold apis to use random id during the collections initialization and made index columns check on the UI case insensitive 2025-08-26 22:02:00 +03:00
Gani Georgiev 5e67ec1c1c [#7135] merge scaffold collection indexes 2025-08-26 21:09:44 +03:00
Gani Georgiev ee8c0a66b6 [#7135] merge scaffold collection indexes 2025-08-26 21:07:20 +03:00
Gani Georgiev 5d964c1b1d tidy go.sum 2025-08-23 09:26:08 +03:00
Gani Georgiev c0d37fc64a regenerated jsvm types 2025-08-23 09:20:51 +03:00
Gani Georgiev 58b564557f enabled __hooks in the jsvm migrations 2025-08-23 08:44:30 +03:00
Gani Georgiev 95787da4df updated changelog 2025-08-23 07:54:20 +03:00
Gani Georgiev b99095430a [#7125] registered missing jsvm migrations bindings 2025-08-23 07:45:28 +03:00
Gani Georgiev ad814c5a37 updated changelog 2025-08-22 22:25:27 +03:00
Gani Georgiev c6621ea1ed regenerated ui/dist and jsvm types 2025-08-22 21:32:03 +03:00
Gani Georgiev bda4baac15 updated go deps 2025-08-22 21:24:39 +03:00
Gani Georgiev a2b1b19342 updated random generator tests 2025-08-22 21:20:30 +03:00
Gani Georgiev 819ec1ad5c renamed to execve to make it more clear 2025-08-22 20:47:58 +03:00
Jesse Sivonen 3ca6321907 [#7116] exclude syscall.Exec for WASM 2025-08-22 20:44:13 +03:00
Gani Georgiev b8f18bd97d added more tests and extra debug log 2025-08-20 22:41:33 +03:00
Gani Georgiev 50dbb7f94f [#7090] try to forward the Apple OAuth2 redirect user's name to the auth handler 2025-08-16 21:30:43 +03:00
Gani Georgiev 09ce863a40 [#7098] fixed RateLimitRule.Audience code comment 2025-08-15 19:29:42 +03:00
Gani Georgiev 13cec96013 regenerated JSVM types 2025-08-12 21:46:18 +03:00
Gani Georgiev b1f1d19d7f bumped min go github action version 2025-08-09 10:13:51 +03:00
Gani Georgiev 5200f9c493 regenerated jsvm types 2025-08-02 08:40:44 +03:00
Gani Georgiev 2c8aa2e5fa removed duplicated CHANGELOG entry 2025-08-02 08:38:46 +03:00
Gani Georgiev eeae1f64ee updated changelog 2025-08-02 08:24:39 +03:00
Gani Georgiev 506172c495 removed unnecessary space 2025-08-02 08:24:27 +03:00
Gani Georgiev d75f5f663c [#7067] explain more clearly the DynamicModel caveats 2025-08-02 08:13:15 +03:00
Gani Georgiev 92e15c287e updated modernc.org/sqlite to 1.38.2 2025-08-02 08:05:06 +03:00
Gani GeorgievandBlake Patteson dd895dee01 [#7056] added Box OAuth2 provider
Co-authored-by: Blake Patteson <bpatteson@me.com>
2025-08-02 07:50:49 +03:00
Gani Georgiev 4b2e75992b updated ui/dist 2025-07-26 23:10:46 +03:00
Gani Georgiev c498c918ec updated go deps 2025-07-26 22:49:17 +03:00
Gani Georgiev 125e99e4c8 [#7035] updated the X/Twitter provider to return the confirmed_email field and to use the x.com domain 2025-07-26 22:45:02 +03:00
Gani Georgiev 5461120b04 [#7049] fixed list api example response 2025-07-25 19:56:41 +03:00
Gani Georgiev 4a7fb95650 updated jsvm types 2025-07-19 11:31:26 +03:00
Gani Georgiev 444fa78252 updated go deps 2025-07-19 11:01:35 +03:00
Gani Georgiev 641aa54cfc use the nonconcurrent pool for running PRAGMA optimize 2025-07-19 10:02:44 +03:00
Gani Georgiev f015911594 updated npm deps and clarified trusted proxy headers input label 2025-07-19 09:41:40 +03:00
Gani Georgiev fadb2e68a2 increased filesystem read buffer to speedup writes 2025-07-19 09:34:01 +03:00
Gani Georgiev 5ca79eb85d [#7022] added support for unmarshaling into interface fields 2025-07-18 23:11:05 +03:00
Gani Georgiev 62c8523070 updated ui/dist 2025-06-29 20:41:36 +03:00
Gani Georgiev 8debafa755 synced with master 2025-06-29 20:30:33 +03:00
Gani Georgiev 0089ceb904 [#6982] disable separator escaping for the page title 2025-06-29 20:28:36 +03:00
Gani Georgiev 6443f2f159 [#3233] added optional ServeEvent.Listener field 2025-06-29 15:41:55 +03:00
Gani Georgiev 0e12169546 updated TestRandomStringByRegex to avoid collisions 2025-06-29 11:51:21 +03:00
Gani Georgiev 9d7856a9eb fixed changelog copy/paste error 2025-06-29 11:33:43 +03:00
Gani Georgiev 306045fa2f updated ui/dist 2025-06-29 11:31:49 +03:00
Gani Georgiev a9c42d0282 [#718] enabled calling auth-refresh with impersonate token 2025-06-29 11:24:50 +03:00
Gani Georgiev f318f461ea updated npm deps 2025-06-26 22:21:17 +03:00
Gani Georgiev 51bc9f3982 [#6972] wrapped backup restore in a transaction as an extra precaution 2025-06-26 22:21:04 +03:00
Gani Georgiev 2c6f99418f added the triggered rate limit rule in the error log details 2025-06-25 20:32:58 +03:00
Gani Georgiev 3f3b77dcd4 print go run in the superuser create installer suggestion if temp dir location is detected 2025-06-24 08:56:53 +03:00
Gani Georgiev db679f9620 regenerated jsvm types 2025-06-21 10:51:52 +03:00
Gani Georgiev c76ee987bd fixed comment typo 2025-06-21 10:51:40 +03:00
Gani Georgiev 0d4da9c3be updated changelog 2025-06-21 10:30:35 +03:00
Gani Georgiev fab56f688d clarified batch max requests input 2025-06-21 10:29:54 +03:00
Felix 1610729d92 [#6947] fixed probability distribution in RandomStringByRegex 2025-06-21 08:03:21 +03:00
Gani Georgiev 6522bc55b1 fixed comment typo 2025-06-18 19:01:36 +03:00
Gani Georgiev c8776b7cd9 updated jsvm types 2025-06-17 21:23:19 +03:00
Gani Georgiev 262e78c04e [#6935] added toBytes JSVM helper 2025-06-17 21:15:34 +03:00
Gani Georgiev 0a66e5a286 bumped app version 2025-06-09 20:49:58 +03:00
Gani Georgiev cdfaed4fa3 updated Record.ToInt test to accomodate the latest cast update 2025-06-09 20:48:52 +03:00
Gani Georgiev 0f73679c3f updated Go deps 2025-06-09 20:03:05 +03:00
Gani Georgiev e09f71ae74 [#6914] skip empty range header 2025-06-09 19:51:06 +03:00
Gani Georgiev 17082de560 fixed legacy go comments 2025-06-09 19:49:46 +03:00
Gani Georgiev 88bb8c406e updated changelog 2025-05-24 00:44:38 +03:00
Gani Georgiev 2ab50cc77e updated monospace font with latin-ext charset 2025-05-24 00:28:44 +03:00
Gani Georgiev 0025ae80ad [#6869] updated fonts and dependencies 2025-05-24 00:06:00 +03:00
Azat Ismagilov 568c63b29f [#6860] support multiline cast expressions in view collections 2025-05-20 19:40:49 +03:00
Gani Georgiev 6e9a9489a7 added tooltip note about the extra record id field validations 2025-05-17 17:10:16 +03:00
Gani Georgiev e73077e7e7 [#6835] fixed json_each/json_array_length normalizations to properly check for array values 2025-05-13 21:26:33 +03:00
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 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 953feb47fd synced with master 2025-03-22 06:35:26 +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
279 changed files with 11884 additions and 5730 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '>=1.23.6'
go-version: '>=1.24.8'
# 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.
+226 -2
View File
@@ -1,8 +1,232 @@
## v0.30.4
- Fixed `json` field CSS regression introduced with the overflow workaround in v0.30.3 ([#7259](https://github.com/pocketbase/pocketbase/issues/7259)).
## v0.30.3
- Fixed legacy identitity field priority check when a username is a valid email address ([#7256](https://github.com/pocketbase/pocketbase/issues/7256)).
- Workaround autocomplete overflow issue with Firefox 144 ([#7223](https://github.com/pocketbase/pocketbase/issues/7223)).
- Updated `modernc.org/sqlite` to 1.39.1 (SQLite 3.50.4).
## v0.30.2
- Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
## v0.30.1
- ⚠️ Excluded the `lost+found` directory from the backups ([#7208](https://github.com/pocketbase/pocketbase/pull/7208); thanks @lbndev).
_If for some reason you want to keep it, you can restore it by editing the `e.Exclude` list of the `OnBackupCreate` and `OnBackupRestore` hooks._
- Minor tests improvements (disabled initial superuser creation for the test app to avoid cluttering the std output, added more tests for the `s3.Uploader.MaxConcurrency`, etc.).
- Updated `modernc.org/sqlite` and other Go dependencies.
## v0.30.0
- Eagerly escape the S3 request path following the same rules as in the S3 signing header ([#7153](https://github.com/pocketbase/pocketbase/issues/7153)).
- Added Lark OAuth2 provider ([#7130](https://github.com/pocketbase/pocketbase/pull/7130); thanks @mashizora).
- Increased test tokens `exp` claim to minimize eventual issues with reproducible builds ([#7123](https://github.com/pocketbase/pocketbase/issues/7123)).
- Added `os.Root` bindings to the JSVM ([`$os.openRoot`](https://pocketbase.io/jsvm/functions/_os.openRoot.html), [`$os.openInRoot`](https://pocketbase.io/jsvm/functions/_os.openInRoot.html)).
- Added `osutils.IsProbablyGoRun()` helper to loosely check if the program was started using `go run`.
- Various minor UI improvements (updated collections indexes UI, enabled seconds in the datepicker, updated helper texts, etc.).
- ⚠️ Updated the minimum package Go version to 1.24.0 and bumped Go dependencies.
## v0.29.3
- Try to forward Apple OAuth2 POST redirect user's name so that it can be returned (and eventually assigned) with the success response of the all-in-one auth call ([#7090](https://github.com/pocketbase/pocketbase/issues/7090)).
- Fixed `RateLimitRule.Audience` code comment ([#7098](https://github.com/pocketbase/pocketbase/pull/7098); thanks @iustin05).
- Mocked `syscall.Exec` when building for WASM ([#7116](https://github.com/pocketbase/pocketbase/pull/7116); thanks @joas8211).
_Note that WASM is not officially supported PocketBase build target and many things may not work as expected._
- Registered missing `$filesystem`, `$mails`, `$template` and `__hooks` bindings in the JSVM migrations ([#7125](https://github.com/pocketbase/pocketbase/issues/7125)).
- Regenerated JSVM types to include methods from structs with single generic parameter.
- Updated Go dependencies.
## v0.29.2
- Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
## v0.29.1
- Updated the X/Twitter provider to return the `confirmed_email` field and to use the `x.com` domain ([#7035](https://github.com/pocketbase/pocketbase/issues/7035)).
- Added Box.com OAuth2 provider ([#7056](https://github.com/pocketbase/pocketbase/pull/7056); thanks @blakepatteson).
- Updated `modernc.org/sqlite` to 1.38.2 (SQLite 3.50.3).
- Fixed example List API response ([#7049](https://github.com/pocketbase/pocketbase/pull/7049); thanks @williamtguerra).
## v0.29.0
- Enabled calling the `/auth-refresh` endpoint with nonrenewable tokens.
_When used with nonrenewable tokens (e.g. impersonate) the endpoint will simply return the same token with the up-to-date user data associated with it._
- Added the triggered rate rimit rule in the error log `details`.
- Added optional `ServeEvent.Listener` field to initialize a custom network listener (e.g. `unix`) instead of the default `tcp` ([#3233](https://github.com/pocketbase/pocketbase/discussions/3233)).
- Fixed request data unmarshalization for the `DynamicModel` array/object fields ([#7022](https://github.com/pocketbase/pocketbase/discussions/7022)).
- Fixed Dashboard page title `-` escaping ([#6982](https://github.com/pocketbase/pocketbase/issues/6982)).
- Other minor improvements (updated first superuser console text when running with `go run`, clarified trusted IP proxy header label, wrapped the backup restore in a transaction as an extra precaution, updated deps, etc.).
## v0.28.4
- Added global JSVM `toBytes()` helper to return the bytes slice representation of a value such as io.Reader or string, _other types are first serialized to Go string_ ([#6935](https://github.com/pocketbase/pocketbase/issues/6935)).
- Fixed `security.RandomStringByRegex` random distribution ([#6947](https://github.com/pocketbase/pocketbase/pull/6947); thanks @yerTools).
- Minor docs and typos fixes.
## v0.28.3
- Skip sending empty `Range` header when fetching blobs from S3 ([#6914](https://github.com/pocketbase/pocketbase/pull/6914)).
- Updated Go deps and particularly `modernc.org/sqlite` to 1.38.0 (SQLite 3.50.1).
- Bumped GitHub action min Go version to 1.23.10 as it comes with some [minor security `net/http` fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.10+label%3ACherryPickApproved).
## v0.28.2
- Loaded latin-ext charset for the default text fonts ([#6869](https://github.com/pocketbase/pocketbase/issues/6869)).
- Updated view query CAST regex to properly recognize multiline expressions ([#6860](https://github.com/pocketbase/pocketbase/pull/6860); thanks @azat-ismagilov).
- Updated Go and npm dependencies.
## v0.28.1
- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)).
## 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 clipboard copy.
- Fixed logs details copy to clipboard action.
- Updated `modernc.org/sqlite` to 1.36.2.
@@ -365,7 +589,7 @@ There are a lot of changes but to highlight some of the most notable ones:
- Admins are now system `_superusers` auth records.
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
- Batch/transactional Web API endpoint.
- Impersonate Web API endpoint (_it could be also used for generating fixed/non-refreshable superuser tokens, aka. "API keys"_).
- Impersonate Web API endpoint (_it could be also used for generating fixed/nonrenewable superuser tokens, aka. "API keys"_).
- Support for custom user request activity log attributes.
- One-Time Password (OTP) auth method (_via email code_).
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
+15
View File
@@ -2,6 +2,21 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.36
- (_Backported from v0.30.2_) Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
## v0.22.35
- (_Backported from v0.29.2_) Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
## 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)).
+2
View File
@@ -110,9 +110,11 @@ linux 386
linux amd64
linux arm
linux arm64
linux loong64
linux ppc64le
linux riscv64
linux s390x
windows 386
windows amd64
windows arm64
```
+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)
},
}
+22 -9
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
)
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
@@ -45,7 +46,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 +63,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 +103,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 +135,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 +168,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)
})
})
}
@@ -182,14 +193,16 @@ func collectionTruncate(e *core.RequestEvent) error {
}
func collectionScaffolds(e *core.RequestEvent) error {
randomId := security.RandomStringWithAlphabet(10, core.DefaultIdAlphabet) // could be used as part of the default indexes name
collections := map[string]*core.Collection{
core.CollectionTypeBase: core.NewBaseCollection(""),
core.CollectionTypeAuth: core.NewAuthCollection(""),
core.CollectionTypeView: core.NewViewCollection(""),
core.CollectionTypeBase: core.NewBaseCollection("", randomId),
core.CollectionTypeAuth: core.NewAuthCollection("", randomId),
core.CollectionTypeView: core.NewViewCollection("", randomId),
}
for _, c := range collections {
c.Id = "" // clear autogenerated id
c.Id = "" // clear random id
}
return e.JSON(http.StatusOK, collections)
+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)
}
+9 -1
View File
@@ -32,7 +32,7 @@ func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL st
_ = osutils.LaunchURL(url)
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
color.New(color.Bold).Add(color.FgCyan).Println(url)
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0])
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", executablePath())
return nil
}
@@ -86,3 +86,11 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
return record, nil
}
func executablePath() string {
if osutils.IsProbablyGoRun() {
return "go run ."
}
return os.Args[0]
}
+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 {
+2 -1
View File
@@ -1,6 +1,7 @@
package apis
import (
"errors"
"sync"
"time"
@@ -167,7 +168,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
}
if !rt.isAllowed(key) {
return e.TooManyRequestsError("", nil)
return e.TooManyRequestsError("", errors.New("triggered rate limit rule: "+rule.String()))
}
return nil
+1 -1
View File
@@ -118,7 +118,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/rate/guest", 0, false, 429},
// "guest" rule with regular user (should fallback to the /rate/ rule)
{"/rate/guest", 1, true, 200},
{"/rate/guest", 1.1, true, 200},
{"/rate/guest", 0, true, 200},
{"/rate/guest", 0, true, 429},
{"/rate/guest", 0, true, 429},
+4 -2
View File
@@ -213,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)
})
})
}
@@ -756,7 +758,7 @@ func realtimeCanAccessRecord(
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})
+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
// -----------------------------------------------------------
+13 -7
View File
@@ -12,18 +12,24 @@ func recordAuthRefresh(e *core.RequestEvent) error {
return e.NotFoundError("Missing auth record context.", nil)
}
currentToken := getAuthTokenFromRequest(e)
claims, _ := security.ParseUnverifiedJWT(currentToken)
if v, ok := claims[core.TokenClaimRefreshable]; !ok || !cast.ToBool(v) {
return e.ForbiddenError("The current auth token is not refreshable.", nil)
}
event := new(core.RecordAuthRefreshRequestEvent)
event.RequestEvent = e
event.Collection = record.Collection()
event.Record = record
return e.App.OnRecordAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshRequestEvent) error {
return RecordAuthResponse(e.RequestEvent, e.Record, "", nil)
token := getAuthTokenFromRequest(e.RequestEvent)
// skip token renewal if the token's payload doesn't explicitly allow it (e.g. impersonate tokens)
claims, _ := security.ParseUnverifiedJWT(token) //
if v, ok := claims[core.TokenClaimRefreshable]; ok && cast.ToBool(v) {
var tokenErr error
token, tokenErr = e.Record.NewAuthToken()
if tokenErr != nil {
return e.InternalServerError("Failed to refresh auth token.", tokenErr)
}
}
return recordAuthResponse(e.RequestEvent, e.Record, token, "", nil)
})
}
+32 -12
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"testing"
@@ -74,6 +73,8 @@ func TestRecordAuthRefresh(t *testing.T) {
},
NotExpectedContent: []string{
`"missing":`,
// should return a different token
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedEvents: map[string]int{
"*": 0,
@@ -89,9 +90,21 @@ func TestRecordAuthRefresh(t *testing.T) {
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
ExpectedStatus: 200,
ExpectedContent: []string{
// should return the same token
`"token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak"`,
`"record":`,
`"id":"4q1xlclmfloku33"`,
`"emailVisibility":false`,
`"email":"test@example.com"`, // the owner can always view their email address
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthRefreshRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
},
},
{
Name: "unverified auth record in onlyVerified collection",
@@ -130,23 +143,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
// -----------------------------------------------------------
+14
View File
@@ -15,6 +15,7 @@ import (
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"
@@ -90,6 +91,19 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 user.", err))
}
// Apple currently returns the user's name only as part of the first redirect data response
// so we try to assign the [apis.oauth2SubscriptionRedirect] forwarded name.
if form.Provider == auth.NameApple && authUser.Name == "" {
nameKey := oauth2RedirectAppleNameStoreKeyPrefix + form.Code
name, ok := e.App.Store().Get(nameKey).(string)
if ok {
e.App.Store().Remove(nameKey)
authUser.Name = name
} else {
e.App.Logger().Debug("Missing or already removed Apple user's name")
}
}
var authRecord *core.Record
// check for existing relation with the auth collection
+77 -3
View File
@@ -2,22 +2,29 @@ package apis
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
const (
oauth2SubscriptionTopic string = "@oauth2"
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
oauth2SubscriptionTopic string = "@oauth2"
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
oauth2RedirectAppleNameStoreKeyPrefix string = "@redirect_name_"
)
type oauth2RedirectData struct {
State string `form:"state" json:"state"`
Code string `form:"code" json:"code"`
Error string `form:"error" json:"error,omitempty"`
// returned by Apple only
AppleUser string `form:"user" json:"-"`
}
func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
@@ -52,6 +59,20 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
}
defer client.Unsubscribe(oauth2SubscriptionTopic)
// temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
// (see https://github.com/pocketbase/pocketbase/issues/7090)
if data.AppleUser != "" && data.Error == "" && data.Code != "" {
nameErr := parseAndStoreAppleRedirectName(
e.App,
oauth2RedirectAppleNameStoreKeyPrefix+data.Code,
data.AppleUser,
)
if nameErr != nil {
// non-critical error
e.App.Logger().Debug("Failed to parse and load Apple Redirect name data", "error", nameErr)
}
}
encodedData, err := json.Marshal(data)
if err != nil {
e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
@@ -72,3 +93,56 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
return e.Redirect(redirectStatusCode, oauth2RedirectSuccessPath)
}
// parseAndStoreAppleRedirectName extracts the first and last name
// from serializedNameData and temporary store them in the app.Store.
//
// This is hacky workaround to forward safely and seamlessly the Apple
// redirect user's name back to the OAuth2 auth handler.
//
// Note that currently Apple is the only provider that behaves like this and
// for now it is unnecessary to check whether the redirect is coming from Apple or not.
//
// Ideally this shouldn't be needed and will be removed in the future
// once Apple adds a dedicated userinfo endpoint.
func parseAndStoreAppleRedirectName(app core.App, nameKey string, serializedNameData string) error {
if serializedNameData == "" {
return nil
}
// just in case to prevent storing large strings in memory
if len(nameKey) > 1000 {
return errors.New("nameKey is too large")
}
// https://developer.apple.com/documentation/signinwithapple/incorporating-sign-in-with-apple-into-other-platforms#Handle-the-response
extracted := struct {
Name struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"name"`
}{}
if err := json.Unmarshal([]byte(serializedNameData), &extracted); err != nil {
return err
}
fullName := extracted.Name.FirstName + " " + extracted.Name.LastName
// truncate just in case to prevent storing large strings in memory
if len(fullName) > 150 {
fullName = fullName[:150]
}
fullName = strings.TrimSpace(fullName)
if fullName == "" {
return nil
}
// store (and remove)
app.Store().Set(nameKey, fullName)
time.AfterFunc(1*time.Minute, func() {
app.Store().Remove(nameKey)
})
return nil
}
@@ -3,6 +3,7 @@ package apis_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"time"
@@ -266,6 +267,74 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
}
},
},
{
Name: "(POST) Apple user's name json (nameKey error)",
Method: http.MethodPost,
URL: "/api/oauth2-redirect",
Body: strings.NewReader(url.Values{
"code": []string{strings.Repeat("a", 986)},
"state": []string{clientStubs[8]["c3"].Id()},
"user": []string{
`{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
},
}.Encode()),
Headers: map[string]string{
"content-type": "application/x-www-form-urlencoded",
},
BeforeTestFunc: beforeTestFunc(clientStubs[8], map[string][]string{
"c3": {`"state":"` + clientStubs[8]["c3"].Id(), `"code":"` + strings.Repeat("a", 986) + `"`},
}),
ExpectedStatus: http.StatusSeeOther,
ExpectedEvents: map[string]int{"*": 0},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkSuccessRedirect(t, app, res)
if clientStubs[8]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
if storedName := app.Store().Get("@redirect_name_" + strings.Repeat("a", 986)); storedName != nil {
t.Fatalf("Didn't expect stored name, got %q", storedName)
}
},
},
{
Name: "(POST) Apple user's name json",
Method: http.MethodPost,
URL: "/api/oauth2-redirect",
Body: strings.NewReader(url.Values{
"code": []string{strings.Repeat("a", 985)},
"state": []string{clientStubs[9]["c3"].Id()},
"user": []string{
`{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
},
}.Encode()),
Headers: map[string]string{
"content-type": "application/x-www-form-urlencoded",
},
BeforeTestFunc: beforeTestFunc(clientStubs[9], map[string][]string{
"c3": {`"state":"` + clientStubs[9]["c3"].Id(), `"code":"` + strings.Repeat("a", 985) + `"`},
}),
ExpectedStatus: http.StatusSeeOther,
ExpectedEvents: map[string]int{"*": 0},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkSuccessRedirect(t, app, res)
if clientStubs[9]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
storedName, _ := app.Store().Get("@redirect_name_" + strings.Repeat("a", 985)).(string)
expectedName := "aaa " + strings.Repeat("b", 146)
if storedName != expectedName {
t.Fatalf("Expected stored name\n%q\ngot\n%q", expectedName, storedName)
}
},
},
}
for _, scenario := range scenarios {
+161
View File
@@ -1577,6 +1577,167 @@ 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"},
},
// Apple AuthUser.Name assign checks
// -----------------------------------------------------------
{
Name: "store name with Apple provider",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "apple",
"code":"test_code",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers[auth.NameApple] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
app.Store().Set("@redirect_name_test_code", "test_store_name")
// add the test provider in the collection
users.MFA.Enabled = false
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: auth.NameApple,
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(users); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"meta":{`,
`"name":"test_store_name"`,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.Store().Has("@redirect_name_test_code") {
t.Fatal("Expected @redirect_name_test_code store key to be removed")
}
},
},
{
Name: "store name with non-Apple provider",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"test_code",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
users, 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"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
app.Store().Set("@redirect_name_test_code", "test_store_name")
// add the test provider in the collection
users.MFA.Enabled = false
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(users); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
NotExpectedContent: []string{
`"name":"test_store_name"`,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if !app.Store().Has("@redirect_name_test_code") {
t.Fatal("Expected @redirect_name_test_code store key to NOT be deleted")
}
},
},
// rate limit checks
// -----------------------------------------------------------
+47
View File
@@ -419,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
// -----------------------------------------------------------
+23 -14
View File
@@ -40,23 +40,32 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
if form.IdentityField != "" {
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 = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
identityFields := collection.PasswordAuth.IdentityFields
// @todo consider removing with the stable release or moving it in the collection save
//
// prioritize email lookup to minimize breaking changes with earlier versions
if len(identityFields) > 1 && identityFields[0] != core.FieldNameEmail {
identityFields = slices.Clone(identityFields)
slices.SortStableFunc(identityFields, func(a, b string) int {
if a == "email" {
return -1
}
if b == "email" {
return 1
}
return 0
})
}
// search by the other identity fields
if !isEmail || foundErr != nil {
for _, name := range collection.PasswordAuth.IdentityFields {
if !isEmail && name == core.FieldNameEmail {
continue // no need to search by the email field if it is not an email
}
for _, name := range identityFields {
if name == core.FieldNameEmail && is.EmailFormat.Validate(form.Identity) != nil {
continue // no need to query the database if we know that the submitted value is not an email
}
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
if foundErr == nil {
break
}
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
if foundErr == nil {
break
}
}
}
+65 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -82,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(`{
@@ -91,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",
@@ -206,6 +212,57 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnMailerRecordAuthAlertSend": 1,
},
},
{
// https://github.com/pocketbase/pocketbase/issues/7256
Name: "valid non-email identity field with a value that is a properly formatted email",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"username_as_email@example.com",
"password":"1234567890"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
record, err := app.FindAuthRecordByEmail("clients", "test@example.com")
if err != nil {
t.Fatal(err)
}
record.Set("username", "username_as_email@example.com")
err = app.SaveNoValidate(record)
if err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"username_as_email@example.com"`,
`"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: "unknown explicit identityField",
Method: http.MethodPost,
+101 -87
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 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.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,15 +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{
// note: use the original record id and not e.Record.Id because the record validations because may get overwritten
e.Collection.Name + ".id": e.Record.LastSavedPK(),
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
form.GrantManagerAccess()
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to update record.", err))
@@ -471,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
}
@@ -501,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 {
@@ -561,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
}
+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",
+26 -3
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)
})
})
}
@@ -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
+17 -12
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()
@@ -153,13 +153,6 @@ func Serve(app core.App, config ServeConfig) error {
ErrorLog: log.New(&serverErrorLogWriter{app: app}, "", 0),
}
serveEvent := new(core.ServeEvent)
serveEvent.App = app
serveEvent.Router = pbRouter
serveEvent.Server = server
serveEvent.CertManager = certManager
serveEvent.InstallerFunc = DefaultInstallerFunc
var listener net.Listener
// graceful shutdown
@@ -207,6 +200,13 @@ func Serve(app core.App, config ServeConfig) error {
var baseURL string
serveEvent := new(core.ServeEvent)
serveEvent.App = app
serveEvent.Router = pbRouter
serveEvent.Server = server
serveEvent.CertManager = certManager
serveEvent.InstallerFunc = DefaultInstallerFunc
// trigger the OnServe hook and start the tcp listener
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
handler, err := e.Router.BuildMux()
@@ -237,9 +237,13 @@ func Serve(app core.App, config ServeConfig) error {
}
}
listener, err = net.Listen("tcp", addr)
if err != nil {
return err
if e.Listener == nil {
listener, err = net.Listen("tcp", addr)
if err != nil {
return err
}
} else {
listener = e.Listener
}
if e.InstallerFunc != nil {
@@ -259,7 +263,8 @@ func Serve(app core.App, config ServeConfig) error {
}
if listener == nil {
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
//nolint:staticcheck
return errors.New("The OnServe listener was not initialized. Did you forget to call the ServeEvent.Next() method?")
}
if config.ShowStartBanner {
+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())
+60 -20
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
+146 -33
View File
@@ -12,7 +12,6 @@ import (
"regexp"
"runtime"
"strings"
"syscall"
"time"
"github.com/fatih/color"
@@ -26,6 +25,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 (
@@ -39,6 +40,9 @@ const (
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
LocalAutocertCacheDirName string = ".autocert_cache"
// @todo consider removing after backups refactoring
lostFoundDirName string = "lost+found"
)
// FilesManager defines an interface with common methods that files manager models should implement.
@@ -69,7 +73,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 +364,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 +478,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 +727,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
@@ -707,7 +775,7 @@ func (app *BaseApp) Restart() error {
}
}()
return syscall.Exec(execPath, os.Args, os.Environ())
return execve(execPath, os.Args, os.Environ())
})
}
@@ -1145,10 +1213,10 @@ var sqlLogReplacements = []struct {
}{
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
{regexp.MustCompile(`([^'"])\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])`), "`$1"},
{regexp.MustCompile(`([^'"])\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])`), "`$1"},
{regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
{regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
{regexp.MustCompile(`<nil>`), "NULL"},
}
@@ -1190,6 +1258,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()
@@ -1206,26 +1301,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()
@@ -1254,7 +1367,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.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
}
+35 -21
View File
@@ -54,7 +54,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
event.Context = ctx
event.Name = name
// default root dir entries to exclude from the backup generation
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
// generate a default name if missing
@@ -71,7 +71,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
// archive pb_data in a temp directory, exluding the "backups" and the temp dirs
//
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
// run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection)
// ---
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
createErr := e.App.RunInTransaction(func(txApp App) error {
@@ -159,7 +159,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
event.Context = ctx
event.Name = name
// default root dir entries to exclude from the backup restore
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
return app.OnBackupRestore().Trigger(event, func(e *BackupEvent) error {
if runtime.GOOS == "windows" {
@@ -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
}
@@ -228,7 +228,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
} else {
// manually construct the local path to avoid creating a copy of the zip file
// since the blob reader currently doesn't implement ReaderAt
zipPath := filepath.Join(app.DataDir(), LocalBackupsDirName, filepath.Base(name))
zipPath := filepath.Join(e.App.DataDir(), LocalBackupsDirName, filepath.Base(name))
err = archive.Extract(zipPath, extractedDataDir)
if err != nil {
@@ -242,29 +242,43 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return fmt.Errorf("data.db file is missing or invalid: %w", err)
}
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
if err := osutils.MoveDirContent(e.App.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
// move the extracted archive content to the app's pb_data
if err := osutils.MoveDirContent(extractedDataDir, e.App.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
replaceErr := e.App.RunInTransaction(func(txApp App) error {
return txApp.AuxRunInTransaction(func(txApp App) error {
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
if err := osutils.MoveDirContent(txApp.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
// move the extracted archive content to the app's pb_data
if err := osutils.MoveDirContent(extractedDataDir, txApp.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
}
return nil
})
})
if replaceErr != nil {
return replaceErr
}
revertDataDirChanges := func() error {
if err := osutils.MoveDirContent(e.App.DataDir(), extractedDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
}
return e.App.RunInTransaction(func(txApp App) error {
return txApp.AuxRunInTransaction(func(txApp App) error {
if err := osutils.MoveDirContent(txApp.DataDir(), extractedDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
}
if err := osutils.MoveDirContent(oldTempDataDir, e.App.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
}
if err := osutils.MoveDirContent(oldTempDataDir, txApp.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
}
return nil
return nil
})
})
}
// restart the app
+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,
+1 -1
View File
@@ -1062,7 +1062,7 @@ func (c *Collection) fieldIndexName(field string) string {
} else if c.Name != "" {
name += c.Name
} else {
name += security.PseudorandomString(10)
name += security.PseudorandomStringWithAlphabet(10, DefaultIdAlphabet)
}
if len(name) > 64 {
+4 -1
View File
@@ -1012,18 +1012,21 @@ func TestCollectionModelEventSync(t *testing.T) {
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]
}
@@ -1620,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 {
+2 -2
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
}
@@ -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.NonconcurrentDB().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
}
}
+2 -2
View File
@@ -87,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)),
),
@@ -558,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})).
+8 -8
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 {
@@ -151,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
}
@@ -342,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
}
@@ -426,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
}
@@ -484,7 +484,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
var exists int
rowErr := app.DB().Select("(1)").
rowErr := app.ConcurrentDB().Select("(1)").
From(collection.Name).
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
+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)
}
+12 -14
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,15 +96,15 @@ 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 {
@@ -120,16 +120,14 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
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()
+6
View File
@@ -2,6 +2,7 @@ package core
import (
"context"
"net"
"net/http"
"time"
@@ -104,6 +105,11 @@ type ServeEvent struct {
Server *http.Server
CertManager *autocert.Manager
// Listener allow specifying a custom network listener.
//
// Leave it nil to use the default net.Listen("tcp", e.Server.Addr).
Listener net.Listener
// InstallerFunc is the "installer" function that is called after
// successful server tcp bind but only if there is no explicit
// superuser record created yet.
+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
+1 -3
View File
@@ -10,7 +10,6 @@ import (
"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"
@@ -357,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()
}
+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)...)).
+1 -1
View File
@@ -194,7 +194,7 @@ 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 int
err := app.DB().
err := app.ConcurrentDB().
Select("(1)").
From(record.TableName()).
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
+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 {
+5 -5
View File
@@ -112,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)
}
}
@@ -151,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)
@@ -204,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)
+3 -2
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 {
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -15,6 +15,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/inflector"
@@ -969,6 +970,13 @@ 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))
@@ -1523,8 +1531,8 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
} else {
query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
`SELECT 1 FROM json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName,
`SELECT 1 FROM %s {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
dbutils.JSONEach(prefixedFieldName),
), dbx.Params{
"jevalue": mainRecord.Id,
})))
+44 -4
View File
@@ -879,7 +879,7 @@ func TestRecordGetInt(t *testing.T) {
{123, 123},
{2.4, 2},
{"123", 123},
{"123.5", 0},
{"123.5", 123},
{false, 0},
{true, 1},
}
@@ -1016,6 +1016,43 @@ func TestRecordGetStringSlice(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()
@@ -1659,18 +1696,21 @@ func TestRecordModelEventSync(t *testing.T) {
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]
}
@@ -2214,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")
@@ -2240,7 +2280,7 @@ func TestRecordDelete(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
}
+1 -1
View File
@@ -35,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 {
+1 -1
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
+12 -2
View File
@@ -676,8 +676,8 @@ type RateLimitRule struct {
// Audience specifies the auth group the rule should apply for:
// - "" - both guests and authenticated users (default)
// - "guest" - only for guests
// - "auth" - only for authenticated users
// - "@guest" - only for guests
// - "@auth" - only for authenticated users
Audience string `form:"audience" json:"audience"`
// Duration specifies the interval (in seconds) per which to reset
@@ -704,3 +704,13 @@ func (c RateLimitRule) Validate() error {
func (c RateLimitRule) DurationTime() time.Duration {
return time.Duration(c.Duration) * time.Second
}
// String returns a string representation of the rule.
func (c RateLimitRule) String() string {
raw, err := json.Marshal(c)
if err != nil {
return c.Label // extremely rare case
}
return string(raw)
}
+39 -5
View File
@@ -687,7 +687,7 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
func TestRateLimitRuleValidate(t *testing.T) {
scenarios := []struct {
name string
config core.RateLimitRule
rule core.RateLimitRule
expectedErrors []string
}{
{
@@ -784,7 +784,7 @@ func TestRateLimitRuleValidate(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.config.Validate()
result := s.rule.Validate()
tests.TestValidationErrors(t, result, s.expectedErrors)
})
@@ -793,7 +793,7 @@ func TestRateLimitRuleValidate(t *testing.T) {
func TestRateLimitRuleDurationTime(t *testing.T) {
scenarios := []struct {
config core.RateLimitRule
rule core.RateLimitRule
expected time.Duration
}{
{core.RateLimitRule{}, 0 * time.Second},
@@ -801,8 +801,8 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%d", i, s.config.Duration), func(t *testing.T) {
result := s.config.DurationTime()
t.Run(fmt.Sprintf("%d_%d", i, s.rule.Duration), func(t *testing.T) {
result := s.rule.DurationTime()
if result != s.expected {
t.Fatalf("Expected duration %d, got %d", s.expected, result)
@@ -810,3 +810,37 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
})
}
}
func TestRateLimitRuleString(t *testing.T) {
scenarios := []struct {
name string
rule core.RateLimitRule
expected string
}{
{
"empty",
core.RateLimitRule{},
`{"label":"","audience":"","duration":0,"maxRequests":0}`,
},
{
"all fields",
core.RateLimitRule{
Label: "POST /a/b/",
Duration: 1,
MaxRequests: 2,
Audience: core.RateLimitRuleAudienceAuth,
},
`{"label":"POST /a/b/","audience":"@auth","duration":1,"maxRequests":2}`,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.rule.String()
if result != s.expected {
t.Fatalf("Expected string\n%s\ngot\n%s", s.expected, result)
}
})
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !(js && wasm)
package core
import "syscall"
// execve invokes the execve(2) system call.
func execve(argv0 string, argv []string, envv []string) error {
return syscall.Exec(argv0, argv, envv)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build js && wasm
package core
import "errors"
// https://github.com/pocketbase/pocketbase/pull/7116
func execve(argv0 string, argv []string, envv []string) error {
return errors.ErrUnsupported
}
+1 -1
View File
@@ -215,7 +215,7 @@ func defaultViewField(name string) Field {
}
}
var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
var castRegex = regexp.MustCompile(`(?is)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) {
p := new(identifiersParser)
+32
View File
@@ -394,6 +394,38 @@ func TestCreateViewFields(t *testing.T) {
"max": core.FieldTypeJSON,
},
},
{
"query with multiline cast",
`select
id,
cast(
(
case
when count(a.id) = 1 then 21
when count(a.id) = 2 then 18
else 0
end
) as int
) as cast_int
from demo1 a`,
false,
map[string]string{
"id": core.FieldTypeText,
"cast_int": core.FieldTypeNumber,
},
},
{
"query with case-insensitive and extra-spaced cast",
`select
id,
CaSt( a.id aS iNt ) as cast_int
from demo1 a`,
false,
map[string]string{
"id": core.FieldTypeText,
"cast_int": core.FieldTypeNumber,
},
},
{
"query with reserved auth collection fields",
`
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
@@ -14,6 +13,7 @@ import (
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/osutils"
)
func main() {
@@ -123,8 +123,7 @@ func main() {
// the default pb_public dir location is relative to the executable
func defaultPublicDir() string {
if strings.HasPrefix(os.Args[0], os.TempDir()) {
// most likely ran with go run
if osutils.IsProbablyGoRun() {
return "./pb_public"
}
+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 -24
View File
@@ -1,27 +1,28 @@
module github.com/pocketbase/pocketbase
go 1.23.0
go 1.24.0
require (
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c
github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0
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.10
github.com/ganigeorgiev/fexpr v0.5.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.0
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.9.1
golang.org/x/crypto v0.36.0
golang.org/x/net v0.37.0
golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.12.0
modernc.org/sqlite v1.36.2
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.1
golang.org/x/crypto v0.43.0
golang.org/x/image v0.32.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.17.0
modernc.org/sqlite v1.39.1
)
require (
@@ -35,16 +36,15 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect
modernc.org/libc v1.61.13 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/memory v1.11.0 // indirect
)
+57 -54
View File
@@ -14,10 +14,10 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
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-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/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw=
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -26,18 +26,18 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
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/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-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -55,56 +55,57 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119 h1:TjQtEReJDTpvlNFTRjuHvPQpJHAeJdcQF130eCAAT/o=
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f h1:ahrn66FNJYsFkO0EOTStYs+jdBKBop/anp9hoQSzZjI=
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA=
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -112,26 +113,28 @@ 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.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/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.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
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 -8
View File
@@ -1,28 +1,26 @@
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.36.2"
expectedLibcVersion = "v1.61.13"
expectedDriverVersion = "v1.39.1"
expectedLibcVersion = "v1.66.10"
// 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.
+3 -3
View File
@@ -204,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() {
@@ -227,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 {
+67 -9
View File
@@ -309,6 +309,44 @@ func baseBinds(vm *goja.Runtime) {
return string(bodyBytes), nil
})
// note: throw only on reader error
vm.Set("toBytes", func(raw any, maxReaderBytes int) ([]byte, error) {
switch v := raw.(type) {
case nil:
return []byte{}, nil
case string:
return []byte(v), nil
case []byte:
return v, nil
case types.JSONRaw:
return v, nil
case io.Reader:
if maxReaderBytes == 0 {
maxReaderBytes = router.DefaultMaxMemory
}
limitReader := io.LimitReader(v, int64(maxReaderBytes))
return io.ReadAll(limitReader)
default:
b, err := cast.ToUint8SliceE(v)
if err == nil {
return b, nil
}
str, err := cast.ToStringE(v)
if err == nil {
return []byte(str), nil
}
// as a last attempt try to json encode the value
rawBytes, _ := json.Marshal(raw)
return rawBytes, nil
}
})
// note: throw only on reader error
vm.Set("toString", func(raw any, maxReaderBytes int) (string, error) {
switch v := raw.(type) {
case io.Reader:
@@ -495,6 +533,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 {
@@ -548,9 +590,18 @@ func baseBinds(vm *goja.Runtime) {
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)
@@ -719,6 +770,8 @@ func osBinds(vm *goja.Runtime) {
obj.Set("rename", os.Rename)
obj.Set("remove", os.Remove)
obj.Set("removeAll", os.RemoveAll)
obj.Set("openRoot", os.OpenRoot)
obj.Set("openInRoot", os.OpenInRoot)
}
func formsBinds(vm *goja.Runtime) {
@@ -774,11 +827,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 {
@@ -883,6 +940,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 {
@@ -893,7 +951,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 {
+94 -15
View File
@@ -1,6 +1,7 @@
package jsvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -22,6 +23,7 @@ import (
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -44,7 +46,7 @@ func TestBaseBindsCount(t *testing.T) {
vm := goja.New()
baseBinds(vm)
testBindsCount(vm, "this", 33, t)
testBindsCount(vm, "this", 35, t)
}
func TestBaseBindsSleep(t *testing.T) {
@@ -83,7 +85,7 @@ func TestBaseBindsReaderToString(t *testing.T) {
}
}
func TestBaseBindsToStringAndToBytes(t *testing.T) {
func TestBaseBindsToString(t *testing.T) {
vm := goja.New()
baseBinds(vm)
vm.Set("scenarios", []struct {
@@ -106,10 +108,49 @@ func TestBaseBindsToStringAndToBytes(t *testing.T) {
_, err := vm.RunString(`
for (let s of scenarios) {
let result = toString(s.value)
let str = toString(s.value)
if (str != s.expected) {
throw new Error('[' + s.name + '] Expected string ' + s.expected + ', got ' + str);
}
}
`)
if err != nil {
t.Fatal(err)
}
}
if (result != s.expected) {
throw new Error('[' + s.name + '] Expected string ' + s.expected + ', got ' + result);
func TestBaseBindsToBytes(t *testing.T) {
vm := goja.New()
baseBinds(vm)
vm.Set("bytesEqual", bytes.Equal)
vm.Set("scenarios", []struct {
Name string
Value any
Expected []byte
}{
{"null", nil, []byte{}},
{"string", "test", []byte("test")},
{"number", -12.4, []byte("-12.4")},
{"bool", true, []byte("true")},
{"arr", []int{1, 2, 3}, []byte{1, 2, 3}},
{"jsonraw", types.JSONRaw{1, 2, 3}, []byte{1, 2, 3}},
{"reader", strings.NewReader("test"), []byte("test")},
{"obj", map[string]any{"test": 123}, []byte(`{"test":123}`)},
{"struct", struct {
Name string
private string
}{Name: "123", private: "456"}, []byte(`{"Name":"123"}`)},
})
_, err := vm.RunString(`
for (let s of scenarios) {
let b = toBytes(s.value)
if (!Array.isArray(b)) {
throw new Error('[' + s.name + '] Expected toBytes to return an array');
}
if (!bytesEqual(b, s.expected)) {
throw new Error('[' + s.name + '] Expected bytes ' + s.expected + ', got ' + b);
}
}
`)
@@ -399,6 +440,10 @@ func TestBaseBindsNamedFields(t *testing.T) {
"new FileField({name: 'test'})",
isType[*core.FileField],
},
{
"new GeoPointField({name: 'test'})",
isType[*core.GeoPointField],
},
}
for _, s := range scenarios {
@@ -568,15 +613,35 @@ func TestBaseBindsDateTime(t *testing.T) {
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 {
@@ -1363,6 +1428,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",
@@ -1395,6 +1467,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) {
@@ -1408,13 +1487,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));
}
}
}
@@ -1703,5 +1782,5 @@ func TestOsBindsCount(t *testing.T) {
vm := goja.New()
osBinds(vm)
testBindsCount(vm, "$os", 18, t)
testBindsCount(vm, "$os", 20, 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
}
}
File diff suppressed because it is too large Load Diff
+79 -24
View File
@@ -157,8 +157,6 @@ declare var $app: PocketBase
* ).render({"name": "John"})
* ` + "```" + `
*
* _Note that this method is available only in pb_hooks context._
*
* @namespace
* @group PocketBase
*/
@@ -186,14 +184,46 @@ declare function readerToString(reader: any, maxBytes?: number): string;
* // io.Reader
* const ex1 = toString(e.request.body)
*
* // slice of bytes ("hello")
* const ex2 = toString([104 101 108 108 111])
* // slice of bytes
* const ex2 = toString([104 101 108 108 111]) // "hello"
*
* // null
* const ex3 = toString(null) // ""
* ` + "```" + `
*
* @group PocketBase
*/
declare function toString(val: any, maxBytes?: number): string;
/**
* toBytes converts the specified value into a bytes slice.
*
* Support optional second maxBytes argument to limit the max read bytes
* when the value is a io.Reader (default to 32MB).
*
* Types that don't have Go slice representation (bool, objects, etc.)
* are serialized to UTF8 string and its bytes slice is returned.
*
* Example:
*
* ` + "```" + `js
* // io.Reader
* const ex1 = toBytes(e.request.body)
*
* // string
* const ex2 = toBytes("hello") // [104 101 108 108 111]
*
* // object (the same as the string '{"test":1}')
* const ex3 = toBytes({"test":1}) // [123 34 116 101 115 116 34 58 49 125]
*
* // null
* const ex4 = toBytes(null) // []
* ` + "```" + `
*
* @group PocketBase
*/
declare function toBytes(val: any, maxBytes?: number): Array<number>;
/**
* sleep pauses the current goroutine for at least the specified user duration (in ms).
* A zero or negative duration returns immediately.
@@ -227,7 +257,9 @@ declare function arrayOf<T>(model: T): Array<T>;
/**
* DynamicModel creates a new dynamic model with fields from the provided data shape.
*
* Note that in order to use 0 as double/float initialization number you have to use negative zero (` + "`-0`" + `).
* Caveats:
* - In order to use 0 as double/float initialization number you have to negate it (` + "`-0`" + `).
* - You need to use lowerCamelCase when accessing the model fields (e.g. ` + "`model.roles`" + ` and not ` + "`model.Roles`" + `).
*
* Example:
*
@@ -237,7 +269,7 @@ declare function arrayOf<T>(model: T): Array<T>;
* age: 0, // int64
* totalSpent: -0, // float64
* active: false,
* roles: [],
* Roles: [], // maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles"
* meta: {}
* })
* ` + "```" + `
@@ -482,6 +514,16 @@ declare class FileField implements core.FileField {
constructor(data?: Partial<core.FileField>)
}
interface GeoPointField extends core.GeoPointField{} // merge
/**
* {@inheritDoc core.GeoPointField}
*
* @group PocketBase
*/
declare class GeoPointField implements core.GeoPointField {
constructor(data?: Partial<core.GeoPointField>)
}
interface MailerMessage extends mailer.Message{} // merge
/**
* MailerMessage defines a single email message.
@@ -608,19 +650,27 @@ declare class Timezone implements time.Location {
interface DateTime extends types.DateTime{} // merge
/**
* DateTime defines a single DateTime type instance.
* The returned date is always represented in UTC.
*
* Example:
*
* ` + "```" + `js
* const dt0 = new DateTime() // now
*
* // full datetime string
* const dt1 = new DateTime('2023-07-01 00:00:00.000Z')
*
* // datetime string with default "parse in" timezone location
* //
* // similar to new DateTime('2023-07-01 00:00:00 +01:00') or new DateTime('2023-07-01 00:00:00 +02:00')
* // but accounts for the daylight saving time (DST)
* const dt2 = new DateTime('2023-07-01 00:00:00', 'Europe/Amsterdam')
* ` + "```" + `
*
* @group PocketBase
*/
declare class DateTime implements types.DateTime {
constructor(date?: string)
constructor(date?: string, defaultParseInLocation?: string)
}
interface ValidationError extends ozzo_validation.Error{} // merge
@@ -877,21 +927,23 @@ declare namespace $os {
*/
export let args: Array<string>
export let exit: os.exit
export let getenv: os.getenv
export let dirFS: os.dirFS
export let readFile: os.readFile
export let writeFile: os.writeFile
export let stat: os.stat
export let readDir: os.readDir
export let tempDir: os.tempDir
export let truncate: os.truncate
export let getwd: os.getwd
export let mkdir: os.mkdir
export let mkdirAll: os.mkdirAll
export let rename: os.rename
export let remove: os.remove
export let removeAll: os.removeAll
export let exit: os.exit
export let getenv: os.getenv
export let dirFS: os.dirFS
export let readFile: os.readFile
export let writeFile: os.writeFile
export let stat: os.stat
export let readDir: os.readDir
export let tempDir: os.tempDir
export let truncate: os.truncate
export let getwd: os.getwd
export let mkdir: os.mkdir
export let mkdirAll: os.mkdirAll
export let rename: os.rename
export let remove: os.remove
export let removeAll: os.removeAll
export let openRoot: os.openRoot
export let openInRoot: os.openInRoot
}
// -------------------------------------------------------------------
@@ -1077,7 +1129,7 @@ declare namespace $http {
* console.log(res.statusCode) // the response HTTP status code
* console.log(res.headers) // the response headers (eg. res.headers['X-Custom'][0])
* console.log(res.cookies) // the response cookies (eg. res.cookies.sessionId.value)
* console.log(res.raw) // the response body as plain text
* console.log(res.body) // the response body as raw bytes slice
* console.log(res.json) // the response body as parsed json array or map
* ` + "```" + `
*/
@@ -1094,8 +1146,11 @@ declare namespace $http {
statusCode: number,
headers: { [key:string]: Array<string> },
cookies: { [key:string]: http.Cookie },
raw: string,
json: any,
body: Array<number>,
// @deprecated please use toString(result.body) instead
raw: string,
};
}
+13 -1
View File
@@ -184,7 +184,13 @@ func (p *plugin) registerMigrations() error {
return err
}
absHooksDir, err := filepath.Abs(p.config.HooksDir)
if err != nil {
return err
}
registry := new(require.Registry) // this can be shared by multiple runtimes
templateRegistry := template.NewRegistry()
for file, content := range files {
vm := goja.New()
@@ -200,6 +206,12 @@ func (p *plugin) registerMigrations() error {
osBinds(vm)
filepathBinds(vm)
httpClientBinds(vm)
filesystemBinds(vm)
formsBinds(vm)
mailsBinds(vm)
vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir)
vm.Set("migrate", func(up, down func(txApp core.App) error) {
core.AppMigrations.Register(up, down, file)
@@ -314,7 +326,7 @@ func (p *plugin) registerHooks() error {
func() {
defer func() {
if err := recover(); err != nil {
fmtErr := fmt.Errorf("Failed to execute %s:\n - %v", file, err)
fmtErr := fmt.Errorf("failed to execute %s:\n - %v", file, err)
if p.config.HooksWatch {
color.Red("%v", fmtErr)

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