Compare commits

...
277 Commits
Author SHA1 Message Date
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 75bba1d0f9 updated modernc.org/sqlite 2025-03-25 22:16:32 +02:00
Gani Georgiev 81cc276ab9 fixed logs clipboard data copy 2025-03-25 22:12:43 +02:00
Gani Georgiev ffc848e92d updated ui/dist 2025-03-25 20:00:47 +02:00
Gani Georgiev 778869c318 [#6639] fixed RecordErrorEvent.Error and CollectionErrorEvent.Error sync with ModelErrorEvent.Error 2025-03-25 19:57:57 +02:00
Gani Georgiev 8e7e0aa5ff fixed logs clipboard data copy 2025-03-24 19:36:43 +02:00
Gani Georgiev e718b88391 synced with master 2025-03-24 18:26:25 +02:00
Gani Georgiev 865408a9b4 updated changelogs 2025-03-24 06:31:58 +02:00
Gani Georgiev 7407bdcaf5 [#6631] fixed and normalized logs error serialization 2025-03-23 20:48:05 +02:00
Gani Georgiev 953feb47fd synced with master 2025-03-22 06:35:26 +02:00
Gani Georgiev 16539335be updated golang-jwt 2025-03-22 06:08:31 +02:00
Gani Georgiev 60a4e3f4d2 updated default select field labels 2025-03-20 14:30:00 +02:00
Gani Georgiev d61ec398a2 [#4674] updated select field ui 2025-03-20 13:46:40 +02:00
Gani Georgiev 9f1946057f use gabriel-vasile/mimetype for the mail attachments 2025-03-18 00:52:33 +02:00
Gani Georgiev 824c7db388 updated auth collection create and update examples 2025-03-17 21:19:47 +02:00
Gani Georgiev e6573d2549 [#6600] removed filesystem io.EOF error wrapping 2025-03-16 10:43:21 +02:00
Gani Georgiev 8aa3545efe updated changelog 2025-03-15 14:36:43 +02:00
Gani Georgiev 7f7d451b31 updated go deps 2025-03-15 13:30:41 +02:00
Gani Georgiev 44007a7c8c reorganized sqlLogReplacements 2025-03-15 13:28:39 +02:00
Gani Georgiev 613af90fac bumped app version 2025-03-15 12:41:44 +02:00
Gani Georgiev 64e2931379 fixed sql nested json value dev print log 2025-03-15 12:29:51 +02:00
Gani Georgiev 6e9ecb2b7a updated modernc.org/sqlite dependency 2025-03-14 23:10:28 +02:00
Gani Georgiev 04a3dc7bb7 fixed typos and pass the correct custom abortCtx 2025-03-14 23:09:24 +02:00
Gani Georgiev 24c4b63960 [#6590] apply nullifyMisingField for request.auth.* and request.body.* back relations when the relation field is pointing to a different collection 2025-03-14 23:00:40 +02:00
Gani Georgiev 4ced91f95d updated ui/dist 2025-03-14 09:11:41 +02:00
Gani Georgiev 33613b27b0 [#6481] allowed calling cronAdd and cronRemove from inside other JSVM handlers 2025-03-11 15:00:56 +02:00
Gani Georgiev 4e148f7224 added log warning for async marked JSVM handlers and resolve when possible the returned Promise as fallback 2025-03-10 19:00:46 +02:00
Gani Georgiev 4a7a639df1 added s3blob WriterOptions custom headers test 2025-03-09 20:56:33 +02:00
Gani Georgiev ed36add853 added basic s3blob tests 2025-03-08 22:32:00 +02:00
Gani Georgiev f799083c4f added godoc comments and license notes for the gocloud.dev vendored code 2025-03-08 01:10:13 +02:00
Gani Georgiev 087eaa7ea4 updated tygoja and the generated jsvm types 2025-03-07 20:52:54 +02:00
Gani Georgiev 803ebb8f40 bumped default server timeouts 2025-03-06 21:45:16 +02:00
Gani Georgiev 58dab5bf70 restored DynamicModel types cache 2025-03-06 21:26:57 +02:00
Gani Georgiev 856cc604a7 synced with master 2025-03-06 12:50:07 +02:00
Gani Georgiev 3912874517 updated changelog 2025-03-05 23:56:05 +02:00
Gani Georgiev 5c58703a59 [#6563] fixed DynamicModel object/array props reflect type caching 2025-03-05 23:55:56 +02:00
Gani Georgiev c054c3b0a5 updated ui/dist and jsvm types 2025-03-05 22:19:36 +02:00
Gani Georgiev 3da16b2d74 updated go deps 2025-03-05 20:17:48 +02:00
Gani Georgiev dd5afd44d2 updated modernc.org/sqlite 2025-03-05 16:54:40 +02:00
Gani Georgiev 501c49012e [poc] replaced aws-sdk-go-v2 and gocloud.dev/blob 2025-03-05 16:31:21 +02:00
Gani Georgiev 48a9b82024 synced with master 2025-02-25 20:12:09 +02:00
Gani Georgiev 4155f50fe1 [#6529] added default leeway for the id_token checks 2025-02-24 11:45:59 +02:00
Gani Georgiev 653f2d8b16 [#6493] fixed request.body.json.* values extraction 2025-02-21 18:00:13 +02:00
Gani Georgiev eb3129d1f3 updated ui/dist 2025-02-21 16:54:51 +02:00
Gani Georgiev 4db497c5e1 added subscription.Message.WriteSSE method 2025-02-21 13:04:23 +02:00
Gani Georgiev 973916bb48 synced with master 2025-02-21 12:51:44 +02:00
Gani Georgiev d607695600 [#6490] restore meta.isNew OAuth2 response field 2025-02-21 10:30:43 +02:00
Gani Georgiev 3f51fb941b updated modernc deps and bumped app version 2025-02-18 18:34:38 +02:00
Gani Georgiev 4d40463d8d specified a default goja script name when executing plain JS strings 2025-02-18 12:18:08 +02:00
Gani Georgiev 543b8f5e7a added fields example to the JSON method 2025-02-17 22:35:15 +02:00
Gani Georgiev 8e39998f63 added clarification tooltips for the create and update API rules 2025-02-15 18:40:42 +02:00
Gani Georgiev e8a554b4ad synced with master 2025-02-15 17:04:25 +02:00
Gani Georgiev 5aa380927a dowgraded aws-sdk-go-v2 2025-02-11 20:13:43 +02:00
Gani Georgiev aaa3d67659 added Store.SetFunc method 2025-02-11 13:24:13 +02:00
Gani Georgiev 5c41938cb9 updated logs code comments 2025-02-11 00:38:22 +02:00
Gani Georgiev f1d44c7847 synced with master 2025-02-11 00:25:30 +02:00
Gani Georgiev c0b7762abd [#6440] added a temporary exception for Backblaze S3 endpoints to exclude the new aws-sdk-go-v2 checksum headers 2025-02-10 23:37:23 +02:00
Gani Georgiev 26f0df36bc [#6402] load the request info context during password/OAuth2/OTP authentication 2025-02-10 16:57:25 +02:00
Gani Georgiev 6a7f3a21fb synced with master 2025-02-10 09:38:15 +02:00
Gani Georgiev 2e26f61cb7 updated changelog 2025-02-10 09:15:07 +02:00
Gani Georgiev 59f98cac99 fixed flaky realtime record resolve test 2025-02-09 23:47:08 +02:00
Gani Georgiev 2a1fdc1613 added realtime api record resolve tests 2025-02-09 23:26:41 +02:00
Gani Georgiev f767af0ded bumped app version 2025-02-09 19:41:05 +02:00
Gani Georgiev 920e893e11 [#6433] fixed realtime delete event for RecordProxy and other custom record models 2025-02-09 19:24:45 +02:00
Gani Georgiev 048e534f0d bumped app version 2025-02-08 08:47:01 +02:00
Gani Georgiev 9da7a8f72b [#6407] added os.Stat jsvm binding 2025-02-05 19:07:34 +02:00
Gani Georgiev 9856c59de0 prioritized user submitted OAuth2 createData.email 2025-02-03 12:57:15 +02:00
Gani Georgiev acd72101c6 fixed batch API Preview sample response 2025-02-01 12:04:15 +02:00
Gani Georgiev fa343c37e6 fixed changelog typos 2025-01-31 15:50:07 +02:00
Gani Georgiev d3aac570b2 updated changelog 2025-01-31 15:02:23 +02:00
Gani Georgiev b066d9c429 updated godoc 2025-01-31 14:28:23 +02:00
Gani Georgiev 5d889d00ad Merge branch 'develop' 2025-01-31 14:21:29 +02:00
Gani Georgiev 49baa69e8d updated jsvm types 2025-01-31 14:20:36 +02:00
Gani Georgiev 923b791675 removed unnecessary strings.ToLower 2025-01-31 14:06:48 +02:00
Gani Georgiev 4b489b511d bumped app version and updated dependencies 2025-01-31 13:29:19 +02:00
Gani Georgiev 9b901fcee8 updated changelog and help texts 2025-01-30 17:35:16 +02:00
Gani GeorgievandAidan Rowe c5bd42a23f [#6338] added Trakt OAuth2 provider
Co-authored-by: Aidan Rowe <aidanrowe@gmail.com>
2025-01-27 11:05:30 +02:00
Gani Georgiev 1bc0b78c83 updated API Preview sample error responses 2025-01-26 21:27:57 +02:00
Gani Georgiev 6f3abe7c2d fixed comment typo and updated npm deps 2025-01-26 17:33:08 +02:00
Gani Georgiev 33340a6977 [#6337] added support for case-insensitive password auth 2025-01-26 12:24:37 +02:00
Gani Georgiev c101798516 added inflector.Camelize helper 2025-01-24 14:49:28 +02:00
Gani Georgiev 65440314ce added inflector.Singularize helper 2025-01-24 14:00:41 +02:00
Gani Georgiev 91d4ca5c06 replaced archived survey dep with osutils.YesNoPrompt helper 2025-01-21 21:05:30 +02:00
Gani Georgiev a4a228b368 replaced exists bool db scans with int for broader drivers compatibility 2025-01-20 14:16:00 +02:00
Gani Georgiev 0ebe9c4faa updated npm deps 2025-01-20 14:15:38 +02:00
Gani Georgiev 7da875be14 [#6313] enforced when_required for the new aws sdk request and response cheksum validations 2025-01-19 22:12:27 +02:00
Gani Georgiev 2124b77a2a fixed multiple comment blocks parsing 2025-01-19 22:08:20 +02:00
Gani Georgiev b0e2f67733 fixed multiple comment blocks parsing 2025-01-19 21:04:24 +02:00
Gani Georgiev baf4857bee synced with master 2025-01-18 14:23:13 +02:00
Gani Georgiev 37ff943f67 bumped GitHub action min Go version 2025-01-18 12:48:28 +02:00
Gani Georgiev c7322eec66 updated old v0.22.x changelog 2025-01-18 12:17:07 +02:00
Gani Georgiev 7964669a20 updated changelog 2025-01-18 12:13:55 +02:00
Gani Georgiev b8ea953059 [#6309] fixed fields extraction for view query with multi-level comments 2025-01-18 12:11:36 +02:00
Gani Georgiev f935e2139e bumped modernc sqlite driver version 2025-01-17 16:01:49 +02:00
Gani Georgiev 25dd858c18 execute the delete realtime access checks against the non-transactional app instance 2025-01-17 15:59:39 +02:00
Gani Georgiev c70ca97888 removed unnecessary count 2025-01-17 15:58:57 +02:00
Gani Georgiev 0798b5ccbb updated unmarshaled spelling for consistency 2025-01-12 17:43:16 +02:00
Gani Georgiev e4d637e6e0 added test for partially matched table name/alias as suffix 2025-01-12 13:24:33 +02:00
Gani Georgiev f4108cb354 synced with master 2025-01-12 13:02:51 +02:00
Gani Georgiev 2317695011 bumped app version 2025-01-12 11:40:03 +02:00
Gani Georgiev 8b89bce2a8 [#6281] fixed unique validator error 2025-01-12 11:37:24 +02:00
Gani Georgiev 023428ce8f synced with master 2025-01-11 12:38:11 +02:00
Gani Georgiev 7175992ce4 updated go deps and jsvm types 2025-01-11 12:33:46 +02:00
Gani Georgiev 63fb2b6506 updated README 2025-01-11 12:28:17 +02:00
Gani Georgiev 81f4db855b updated jsvm types 2025-01-10 11:48:09 +02:00
Gani Georgiev e103d987ce soft-deprecated Record.GetUploadedFiles in favour of Record.GetUnsavedFiles 2025-01-10 11:45:55 +02:00
Gani Georgiev 18d0b47aeb synced with master 2025-01-10 09:46:33 +02:00
Gani Georgiev 1e9847e924 reload created or edited records data in the RecordsPicker 2025-01-10 09:41:46 +02:00
Gani Georgiev accc51bf65 updated Google OAuth2 endpoints 2025-01-09 15:06:45 +02:00
Gani Georgiev 0aa21c1d04 synced with master 2025-01-05 15:10:53 +02:00
Gani Georgiev b150a3a98a upgraded to jwt/v5 2025-01-05 11:18:00 +02:00
Gani Georgiev 41f1ff2b5f updated mime types autocomplete list 2025-01-04 21:53:19 +02:00
Gani Georgiev 1286b59f54 synced with master 2025-01-04 09:45:20 +02:00
Gani Georgiev ff3d51ce30 added JSVM new Timezone binding 2025-01-03 17:35:49 +02:00
Gani Georgiev dadbca5248 use the original record id in the update manage rule checks 2025-01-03 17:35:21 +02:00
Gani Georgiev 1e2a923433 added missing time macros in the UI autocomplete 2025-01-02 09:41:50 +02:00
Gani Georgiev 9e46d811d6 fixed changelog typo 2025-01-02 09:15:52 +02:00
365 changed files with 22085 additions and 10603 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '>=1.23.4'
go-version: '>=1.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.
+385 -1
View File
@@ -1,3 +1,387 @@
## 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 copy to clipboard action.
- Updated `modernc.org/sqlite` to 1.36.2.
## v0.26.3
- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
## v0.26.2
- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
## v0.26.1
- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)).
## v0.26.0
- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)).
As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster.
_Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._
- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_).
- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)).
- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)).
- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads.
_If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._
- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)).
- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._)
## v0.25.9
- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)).
## v0.25.8
- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)).
It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_).
## v0.25.7
- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
## v0.25.6
- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
- Updated npm dependencies.
## v0.25.5
- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
## v0.25.4
- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce.
This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli).
For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo:
```
// go.mod
github.com/aws/aws-sdk-go-v2 v1.36.1
github.com/aws/aws-sdk-go-v2/config v1.28.10
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
// after that run
go clean -modcache && go mod tidy
```
_The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._
## v0.25.3
- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)).
## v0.25.2
- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)).
## v0.25.1
- Fixed the batch API Preview success sample response.
- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
## v0.25.0
- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
_For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
- `/v3/userinfo` auth response changes:
```
meta.rawUser.id => meta.rawUser.sub
meta.rawUser.verified_email => meta.rawUser.email_verified
```
- `/v2/auth` query parameters changes:
If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
_You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
- Upgraded to `golang-jwt/jwt/v5`.
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
## v0.24.4
- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
## v0.24.3
- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
- Reload the created/edited records data in the RecordsPicker UI.
- Updated Go dependencies.
## v0.24.2
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
@@ -191,7 +575,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_).
+37
View File
@@ -2,6 +2,43 @@
> 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)).
## v0.22.32
- (_Backported from v0.26.2_) Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
## v0.22.31
- (_Backported from v0.25.5_) Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
## v0.22.30
- (_Backported from v0.24.4_) Fixed fields extraction for view queries with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
## v0.22.29
- (_Backported from v0.23.11_) Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
+6 -3
View File
@@ -25,10 +25,13 @@
## API SDK clients
The easiest way to interact with the API is to use one of the official SDK clients:
The easiest way to interact with the PocketBase Web APIs is to use one of the official SDK clients:
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_Browser, Node.js, React Native_)
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_Web, Mobile, Desktop, CLI_)
You could also check the recommendations in https://pocketbase.io/docs/how-to-use/.
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"gocloud.dev/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
)
func TestBackupsList(t *testing.T) {
@@ -490,7 +490,7 @@ func TestBackupsDownload(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
+5 -5
View File
@@ -49,7 +49,7 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
params["id"] = id // required for the path value
ir.Method = "PATCH"
ir.URL = "/api/collections/" + params["collection"] + "/records/" + id + params["query"]
return recordUpdate(next)
return recordUpdate(false, next)
}
}
@@ -57,16 +57,16 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
// ---
ir.Method = "POST"
ir.URL = "/api/collections/" + params["collection"] + "/records" + params["query"]
return recordCreate(next)
return recordCreate(false, next)
},
regexp.MustCompile(`^POST /api/collections/(?P<collection>[^\/\?]+)/records(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordCreate(next)
return recordCreate(false, next)
},
regexp.MustCompile(`^PATCH /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordUpdate(next)
return recordUpdate(false, next)
},
regexp.MustCompile(`^DELETE /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordDelete(next)
return recordDelete(false, next)
},
}
+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},
+78 -44
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
@@ -83,11 +84,10 @@ func realtimeConnect(e *core.RequestEvent) error {
Data: []byte(`{"clientId":"` + ce.Client.Id() + `"}`),
}
connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
me.Response.Write([]byte("data:"))
me.Response.Write(me.Message.Data)
me.Response.Write([]byte("\n\n"))
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if connectMsgErr != nil {
@@ -122,11 +122,10 @@ func realtimeConnect(e *core.RequestEvent) error {
msgEvent.Client = ce.Client
msgEvent.Message = &msg
msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
me.Response.Write([]byte("data:"))
me.Response.Write(me.Message.Data)
me.Response.Write([]byte("\n\n"))
err := me.Message.WriteSSE(me.Response, me.Client.Id())
if err != nil {
return err
}
return me.Flush()
})
if msgErr != nil {
@@ -214,7 +213,9 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
slog.Any("subscriptions", e.Subscriptions),
)
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -353,7 +354,10 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeBroadcastRecord(e.App, "delete", record, true)
// note: use the outside scoped app instance for the access checks so that the API rules
// are performed out of the delete transaction ensuring that they would still work even if
// a cascade-deleted record's API rule relies on an already deleted parent record
err := realtimeBroadcastRecord(e.App, "delete", record, true, app)
if err != nil {
app.Logger().Debug(
"Failed to dry cache record delete",
@@ -372,14 +376,17 @@ func bindRealtimeEvents(app core.App) {
// delete: broadcast
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeBroadcastDryCachedRecord(e.App, "delete", record)
// note: only ensure that it is a collection record
// and don't use realtimeResolveRecord because in case of a
// custom model it'll fail to resolve since the record is already deleted
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil {
err := realtimeBroadcastDryCacheKey(e.App, getDryCacheKey("delete", e.Model))
if err != nil {
app.Logger().Debug(
"Failed to broadcast record delete",
slog.String("id", record.Id),
slog.String("collectionName", record.Collection().Name),
slog.Any("id", e.Model.PK()),
slog.String("collectionName", collection.Name),
slog.String("error", err.Error()),
)
}
@@ -395,7 +402,7 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelErrorEvent) error {
record := realtimeResolveRecord(e.App, e.Model, "")
if record != nil {
err := realtimeUnsetDryCachedRecord(e.App, "delete", record)
err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
if err != nil {
app.Logger().Debug(
"Failed to cleanup after broadcast record delete failure",
@@ -415,7 +422,14 @@ func bindRealtimeEvents(app core.App) {
// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
func realtimeResolveRecord(app core.App, model core.Model, optCollectionType string) *core.Record {
record, _ := model.(*core.Record)
var record *core.Record
switch m := model.(type) {
case *core.Record:
record = m
case core.RecordProxy:
record = m.ProxyRecord()
}
if record != nil {
if optCollectionType == "" || record.Collection().Type == optCollectionType {
return record
@@ -444,14 +458,20 @@ func realtimeResolveRecord(app core.App, model core.Model, optCollectionType str
// realtimeResolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
// This is usually helpful if the provided model is a custom Record model struct.
func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
if record, ok := model.(*core.Record); ok {
collection = record.Collection()
} else {
// check if it is custom Record model struct (ignore "private" tables)
collection, _ = app.FindCachedCollectionByNameOrId(model.TableName())
switch m := model.(type) {
case *core.Record:
return m.Collection()
case core.RecordProxy:
return m.ProxyRecord().Collection()
default:
// check if it is custom Record model struct
collection, err := app.FindCachedCollectionByNameOrId(model.TableName())
if err == nil {
return collection
}
}
return collection
return nil
}
// recordData represents the broadcasted record subscrition message data.
@@ -460,7 +480,11 @@ type recordData struct {
Action string `json:"action"`
}
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool) error {
// Note: the optAccessCheckApp is there in case you want the access check
// to be performed against different db app context (e.g. out of a transaction).
// If set, it is expected that optAccessCheckApp instance is used for read-only operations to avoid deadlocks.
// If not set, it fallbacks to app.
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool, optAccessCheckApp ...core.App) error {
collection := record.Collection()
if collection == nil {
return errors.New("[broadcastRecord] Record collection not set")
@@ -482,10 +506,15 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
(collection.Id + "?"): collection.ListRule,
}
dryCacheKey := action + "/" + record.Id
dryCacheKey := getDryCacheKey(action, record)
group := new(errgroup.Group)
accessCheckApp := app
if len(optAccessCheckApp) > 0 {
accessCheckApp = optAccessCheckApp[0]
}
for _, chunk := range chunks {
group.Go(func() error {
var clientAuth *core.Record
@@ -502,10 +531,6 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
for sub, options := range subs {
// create a clean record copy without expand and unknown fields
// because we don't know yet which exact fields the client subscription has permissions to access
cleanRecord := record.Fresh()
// mock request data
requestInfo := &core.RequestInfo{
Context: core.RequestInfoContextRealtime,
@@ -515,10 +540,14 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
Auth: clientAuth,
}
if !realtimeCanAccessRecord(app, cleanRecord, requestInfo, rule) {
if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
continue
}
// create a clean record copy without expand and unknown fields because we don't know yet
// which exact fields the client subscription requested or has permissions to access
cleanRecord := record.Fresh()
// trigger the enrich hooks
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
// apply expand
@@ -541,7 +570,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
// for auth owner, superuser or manager
if collection.IsAuth() {
if isSameAuth(clientAuth, cleanRecord) ||
realtimeCanAccessRecord(app, cleanRecord, requestInfo, collection.ManageRule) {
realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
cleanRecord.IgnoreEmailVisibility(true)
}
}
@@ -622,15 +651,13 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
return group.Wait()
}
// realtimeBroadcastDryCachedRecord broadcasts all cached record related messages.
func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.Record) error {
// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
func realtimeBroadcastDryCacheKey(app core.App, key string) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
if len(chunks) == 0 {
return nil // no subscribers
}
key := action + "/" + record.Id
group := new(errgroup.Group)
for _, chunk := range chunks {
@@ -659,15 +686,13 @@ func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.
return group.Wait()
}
// realtimeUnsetDryCachedRecord removes the dry cached record related messages.
func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Record) error {
// realtimeUnsetDryCacheKey removes the dry cached key related messages.
func realtimeUnsetDryCacheKey(app core.App, key string) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
if len(chunks) == 0 {
return nil // no subscribers
}
key := action + "/" + record.Id
group := new(errgroup.Group)
for _, chunk := range chunks {
@@ -685,6 +710,15 @@ func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Reco
return group.Wait()
}
func getDryCacheKey(action string, model core.Model) string {
pkStr, ok := model.PK().(string)
if !ok {
pkStr = fmt.Sprintf("%v", model.PK())
}
return action + "/" + model.TableName() + "/" + pkStr
}
func isSameAuth(authA, authB *core.Record) bool {
if authA == nil {
return authB == nil
@@ -722,9 +756,9 @@ func realtimeCanAccessRecord(
return false
}
var exists bool
var exists int
q := app.DB().Select("(1)").
q := app.ConcurrentDB().Select("(1)").
From(record.Collection().Name).
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
@@ -739,5 +773,5 @@ func realtimeCanAccessRecord(
err = q.Limit(1).Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
+251
View File
@@ -2,10 +2,13 @@ package apis_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -14,6 +17,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRealtimeConnect(t *testing.T) {
@@ -632,3 +636,250 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
}
}
// -------------------------------------------------------------------
var _ core.Model = (*CustomModelResolve)(nil)
type CustomModelResolve struct {
core.BaseModel
tableName string
Created string `db:"created"`
}
func (m *CustomModelResolve) TableName() string {
return m.tableName
}
func TestRealtimeRecordResolve(t *testing.T) {
t.Parallel()
const testCollectionName = "realtime_test_collection"
testRecordId := core.GenerateDefaultRandomId()
client0 := subscriptions.NewDefaultClient()
client0.Subscribe(testCollectionName + "/*")
client0.Discard()
// ---
client1 := subscriptions.NewDefaultClient()
client1.Subscribe(testCollectionName + "/*")
// ---
client2 := subscriptions.NewDefaultClient()
client2.Subscribe(testCollectionName + "/" + testRecordId)
// ---
client3 := subscriptions.NewDefaultClient()
client3.Subscribe("demo1/*")
scenarios := []struct {
name string
op func(testApp core.App) error
expected map[string][]string // clientId -> [events]
}{
{
"core.Record",
func(testApp core.App) error {
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
if err != nil {
return err
}
r := core.NewRecord(c)
r.Id = testRecordId
// create
err = testApp.Save(r)
if err != nil {
return err
}
// update
err = testApp.Save(r)
if err != nil {
return err
}
// delete
err = testApp.Delete(r)
if err != nil {
return err
}
return nil
},
map[string][]string{
client1.Id(): {"create", "update", "delete"},
client2.Id(): {"create", "update", "delete"},
},
},
{
"core.RecordProxy",
func(testApp core.App) error {
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
if err != nil {
return err
}
r := core.NewRecord(c)
proxy := &struct {
core.BaseRecordProxy
}{}
proxy.SetProxyRecord(r)
proxy.Id = testRecordId
// create
err = testApp.Save(proxy)
if err != nil {
return err
}
// update
err = testApp.Save(proxy)
if err != nil {
return err
}
// delete
err = testApp.Delete(proxy)
if err != nil {
return err
}
return nil
},
map[string][]string{
client1.Id(): {"create", "update", "delete"},
client2.Id(): {"create", "update", "delete"},
},
},
{
"custom model struct",
func(testApp core.App) error {
m := &CustomModelResolve{tableName: testCollectionName}
m.Id = testRecordId
// create
err := testApp.Save(m)
if err != nil {
return err
}
// update
m.Created = "123"
err = testApp.Save(m)
if err != nil {
return err
}
// delete
err = testApp.Delete(m)
if err != nil {
return err
}
return nil
},
map[string][]string{
client1.Id(): {"create", "update", "delete"},
client2.Id(): {"create", "update", "delete"},
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
// create new test collection with public read access
testCollection := core.NewBaseCollection(testCollectionName)
testCollection.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true, OnUpdate: true})
testCollection.ListRule = types.Pointer("")
testCollection.ViewRule = types.Pointer("")
err := testApp.Save(testCollection)
if err != nil {
t.Fatal(err)
}
testApp.SubscriptionsBroker().Register(client0)
testApp.SubscriptionsBroker().Register(client1)
testApp.SubscriptionsBroker().Register(client2)
testApp.SubscriptionsBroker().Register(client3)
var wg sync.WaitGroup
var notifications = map[string][]string{}
var mu sync.Mutex
notify := func(clientId string, eventData []byte) {
data := struct{ Action string }{}
_ = json.Unmarshal(eventData, &data)
mu.Lock()
defer mu.Unlock()
if notifications[clientId] == nil {
notifications[clientId] = []string{}
}
notifications[clientId] = append(notifications[clientId], data.Action)
}
wg.Add(1)
go func() {
defer wg.Done()
timeout := time.After(250 * time.Millisecond)
for {
select {
case e, ok := <-client0.Channel():
if ok {
notify(client0.Id(), e.Data)
}
case e, ok := <-client1.Channel():
if ok {
notify(client1.Id(), e.Data)
}
case e, ok := <-client2.Channel():
if ok {
notify(client2.Id(), e.Data)
}
case e, ok := <-client3.Channel():
if ok {
notify(client3.Id(), e.Data)
}
case <-timeout:
return
}
}
}()
err = s.op(testApp)
if err != nil {
t.Fatal(err)
}
wg.Wait()
if len(s.expected) != len(notifications) {
t.Fatalf("Expected %d notified clients, got %d:\n%v", len(s.expected), len(notifications), notifications)
}
for id, events := range s.expected {
if len(events) != len(notifications[id]) {
t.Fatalf("[%s] Expected %d events, got %d:\n%v\n%v", id, len(events), len(notifications[id]), s.expected, notifications)
}
for _, event := range events {
if !slices.Contains(notifications[id], event) {
t.Fatalf("[%s] Missing expected event %q in %v", id, event, notifications[id])
}
}
}
})
}
}
+3 -1
View File
@@ -45,7 +45,9 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
+14 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -136,7 +135,7 @@ func TestRecordConfirmEmailChange(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
Name: "OnRecordConfirmEmailChangeRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-email-change",
Body: strings.NewReader(`{
@@ -145,15 +144,22 @@ func TestRecordConfirmEmailChange(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmEmailChangeRequest().BindFunc(func(e *core.RecordConfirmEmailChangeRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmEmailChangeRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmEmailChangeRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -43,7 +43,9 @@ func recordRequestEmailChange(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to request email change.", err))
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -118,6 +118,33 @@ func TestRecordRequestEmailChange(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestEmailChangeRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestEmailChangeRequest().BindFunc(func(e *core.RecordRequestEmailChangeRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestEmailChangeRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+2 -2
View File
@@ -26,10 +26,10 @@ func recordAuthImpersonate(e *core.RequestEvent) error {
form := &impersonateForm{}
if err = e.BindBody(form); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
return e.BadRequestError("An error occurred while loading the submitted data.", err)
}
if err = form.validate(); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
return e.BadRequestError("An error occurred while validating the submitted data.", err)
}
token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
+2 -2
View File
@@ -108,8 +108,8 @@ func recordRequestOTP(e *core.RequestEvent) error {
})
}
return e.JSON(http.StatusOK, map[string]string{
"otpId": otp.Id,
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, map[string]string{"otpId": otp.Id})
})
})
}
+25
View File
@@ -247,6 +247,31 @@ func TestRecordRequestOTP(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestOTPRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-otp",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestOTPRequest().BindFunc(func(e *core.RecordCreateOTPRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestOTPRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+3 -1
View File
@@ -54,7 +54,9 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -282,7 +281,7 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
},
},
{
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
Name: "OnRecordConfirmPasswordResetRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
@@ -292,15 +291,22 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmPasswordResetRequest().BindFunc(func(e *core.RecordConfirmPasswordResetRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmPasswordResetRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmPasswordResetRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -65,7 +65,9 @@ func recordRequestPasswordReset(e *core.RequestEvent) error {
})
})
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -101,6 +101,30 @@ func TestRecordRequestPasswordReset(t *testing.T) {
app.Store().Set(resendKey, struct{}{})
},
},
{
Name: "OnRecordRequestPasswordResetRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestPasswordResetRequest().BindFunc(func(e *core.RecordRequestPasswordResetRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestPasswordResetRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+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
// -----------------------------------------------------------
+46 -11
View File
@@ -3,11 +3,13 @@ package apis
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -34,6 +36,8 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
fallbackAuthRecord = e.Auth
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
form := new(recordOAuth2LoginForm)
form.collection = collection
if err = e.BindBody(form); err != nil {
@@ -87,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
@@ -133,13 +150,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
}
meta := struct {
*auth.AuthUser
IsNew bool `json:"isNew"`
}{
AuthUser: e.OAuth2User,
IsNew: e.IsNewRecord,
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
meta := map[string]any{}
rawOAuth2User, err := json.Marshal(e.OAuth2User)
if err != nil {
return err
}
err = json.Unmarshal(rawOAuth2User, &meta)
if err != nil {
return err
}
meta["isNew"] = e.IsNewRecord
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
})
@@ -194,10 +215,20 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
// ensure that username is unique
checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
if checkUnique {
if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
return false // already exist
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
if hasUniqueue {
var expr dbx.Expression
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
} else {
expr = dbx.HashExp{"username": username}
}
var exists int
_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
if exists > 0 {
return false
}
}
@@ -221,7 +252,11 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload = map[string]any{}
}
payload[core.FieldNameEmail] = e.OAuth2User.Email
// assign the OAuth2 user email only if the user hasn't submitted one
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
payload[core.FieldNameEmail] = e.OAuth2User.Email
}
// map known fields (unless the field was explicitly submitted as part of CreateData)
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
+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 {
+294 -11
View File
@@ -14,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils"
"golang.org/x/oauth2"
)
@@ -174,6 +175,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if err := app.Save(ea); err != nil {
t.Fatal(err)
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextOAuth2 {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -292,6 +307,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test2@example.com"`,
`"id":"oap640cot4yru2s"`,
`"id":"test_id"`,
@@ -383,6 +399,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -488,6 +505,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -601,6 +619,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test_oauth2@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -706,6 +725,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -795,6 +815,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":true`,
`"email":""`,
`"id":"test_id"`,
`"verified":true`,
@@ -876,7 +897,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"verified":{"code":"validation_values_mismatch"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
`"email":`, // ignored because the record validator never ran
`"rel":`, // ignored because the record validator never ran
`"file":`, // ignored because the record validator never ran
},
@@ -929,12 +950,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{"code":"validation_is_email"`,
`"rel":{"code":"validation_missing_rel_records"`,
`"file":{"code":"validation_invalid_file"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
@@ -948,7 +967,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (valid create data)",
Name: "creating user (valid create data with empty submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -956,7 +975,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "invalid",
"email": "",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
@@ -993,6 +1012,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":""`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1040,6 +1060,95 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
},
},
{
Name: "creating user (valid create data with non-empty valid submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "test_create@example.com",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
"name": "test_name",
"username": "test_username",
"rel": "0yxhwia2amd8gec"
}
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
usersCol, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{
Id: "test_id",
Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(usersCol); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test_create@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
`"username":"test_username"`,
`"verified":false`,
`"rel":"0yxhwia2amd8gec"`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordCreateRequest": 1,
"OnRecordEnrich": 2, // the auth response and from the create request
// ---
"OnModelCreate": 3, // record + authOrigins + externalAuths
"OnModelCreateExecute": 3,
"OnModelAfterCreateSuccess": 3,
"OnRecordCreate": 3,
"OnRecordCreateExecute": 3,
"OnRecordAfterCreateSuccess": 3,
// ---
"OnModelValidate": 3,
"OnRecordValidate": 3,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindFirstRecordByData("users", "username", "test_username")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
},
},
{
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
Method: http.MethodPost,
@@ -1092,6 +1201,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1174,6 +1284,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1210,7 +1321,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (with mapped OAuth2 fields and avatarURL->non-file field)",
Name: "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -1230,7 +1341,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
AuthUser: &auth.AuthUser{
Id: "oauth2_id",
Email: "oauth2@example.com",
Username: "oauth2_username",
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
Name: "oauth2_name",
AvatarURL: server.URL + "/oauth2_avatar.png",
},
@@ -1256,9 +1367,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"username":"oauth2_username"`,
`"username":"tESt2_username"`,
`"name":"http://127.`,
`"verified":true`,
`"avatar":""`,
@@ -1294,7 +1406,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (with mapped OAuth2 fields and duplicated username)",
Name: "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -1314,13 +1426,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
AuthUser: &auth.AuthUser{
Id: "oauth2_id",
Email: "oauth2@example.com",
Username: "test2_username",
Username: "tESt2_username",
Name: "oauth2_name",
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// make the username index case-insensitive to ensure that case-insensitive match is used
index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
if ok {
index.Columns[0].Collate = "nocase"
usersCol.RemoveIndex(index.IndexName)
usersCol.Indexes = append(usersCol.Indexes, index.Build())
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
@@ -1338,6 +1458,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1419,6 +1540,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1455,6 +1577,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
// -----------------------------------------------------------
+2
View File
@@ -26,6 +26,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOTP)
event := new(core.RecordAuthWithOTPRequestEvent)
event.RequestEvent = e
event.Collection = collection
+61
View File
@@ -269,6 +269,20 @@ func TestRecordAuthWithOTP(t *testing.T) {
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextOTP {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOTP, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -405,6 +419,53 @@ func TestRecordAuthWithOTP(t *testing.T) {
}
},
},
{
Name: "OnRecordAuthWithOTPRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
"otpId":"` + strings.Repeat("a", 15) + `",
"password":"123456"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// disable MFA
user.Collection().MFA.Enabled = false
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
otp := core.NewOTP(app)
otp.Id = strings.Repeat("a", 15)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
app.OnRecordAuthWithOTPRequest().BindFunc(func(e *core.RecordAuthWithOTPRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordAuthWithOTPRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+42 -4
View File
@@ -3,10 +3,14 @@ package apis
import (
"database/sql"
"errors"
"slices"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -28,16 +32,18 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextPasswordAuth)
var foundRecord *core.Record
var foundErr error
if form.IdentityField != "" {
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, form.IdentityField, form.Identity)
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
} else {
// prioritize email lookup
isEmail := is.EmailFormat.Validate(form.Identity) == nil
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
foundRecord, foundErr = e.App.FindAuthRecordByEmail(collection.Id, form.Identity)
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
}
// search by the other identity fields
@@ -47,7 +53,7 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
continue // no need to search by the email field if it is not an email
}
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, name, form.Identity)
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
if foundErr == nil {
break
}
@@ -92,6 +98,38 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
return validation.ValidateStruct(form,
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
validation.Field(&form.IdentityField, validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...)),
validation.Field(
&form.IdentityField,
validation.Length(1, 255),
validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...),
),
)
}
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
return nil, errors.New("invalid identity field " + field)
}
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field)
if !ok {
return nil, errors.New("missing " + field + " unique index constraint")
}
var expr dbx.Expression
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search
expr = dbx.NewExp("[["+field+"]] = {:identity} COLLATE NOCASE", dbx.Params{"identity": value})
} else {
expr = dbx.HashExp{field: value}
}
record := &core.Record{}
err := app.RecordQuery(collection).AndWhere(expr).Limit(1).One(record)
if err != nil {
return nil, err
}
return record, nil
}
+207 -8
View File
@@ -1,18 +1,44 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
)
func TestRecordAuthWithPassword(t *testing.T) {
t.Parallel()
updateIdentityIndex := func(collectionIdOrName string, fieldCollateMap map[string]string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
collection, err := app.FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
for column, collate := range fieldCollateMap {
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, column)
if !ok {
t.Fatalf("Missing unique identityField index for column %q", column)
}
index.Columns[0].Collate = collate
collection.RemoveIndex(index.IndexName)
collection.Indexes = append(collection.Indexes, index.Build())
}
err = app.Save(collection)
if err != nil {
t.Fatalf("Failed to update identityField index: %v", err)
}
}
}
scenarios := []tests.ApiScenario{
{
Name: "disabled password auth",
@@ -55,7 +81,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "OnRecordAuthWithPasswordRequest error response",
Name: "OnRecordAuthWithPasswordRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
@@ -64,15 +90,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordAuthWithPasswordRequest().BindFunc(func(e *core.RecordAuthWithPasswordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordAuthWithPasswordRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "valid identity field and invalid password",
@@ -99,6 +132,21 @@ func TestRecordAuthWithPassword(t *testing.T) {
"identity":"test@example.com",
"password":"1234567890"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextPasswordAuth {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextPasswordAuth, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
@@ -164,6 +212,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnMailerRecordAuthAlertSend": 1,
},
},
{
Name: "unknown explicit identityField",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identityField": "created",
"identity":"test@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"identityField":{"code":"validation_in_invalid"`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid identity field and valid password with mismatched explicit identityField",
Method: http.MethodPost,
@@ -440,6 +504,141 @@ func TestRecordAuthWithPassword(t *testing.T) {
},
},
// case sensitivity checks
// -----------------------------------------------------------
{
Name: "with explicit identityField (case-sensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identityField": "username",
"identity":"Clients57772",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": ""}),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
},
},
{
Name: "with explicit identityField (case-insensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identityField": "username",
"identity":"Clients57772",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"clients57772"`,
`"token":`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// authOrigin track
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
"OnMailerSend": 1,
"OnMailerRecordAuthAlertSend": 1,
},
},
{
Name: "without explicit identityField and non-email field (case-insensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"Clients57772",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"clients57772"`,
`"token":`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// authOrigin track
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
"OnMailerSend": 1,
"OnMailerRecordAuthAlertSend": 1,
},
},
{
Name: "without explicit identityField and email field (case-insensitive)",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"tESt@example.com",
"password":"1234567890"
}`),
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"email": "nocase"}),
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"clients57772"`,
`"token":`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// authOrigin track
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
"OnMailerSend": 1,
"OnMailerRecordAuthAlertSend": 1,
},
},
// rate limit checks
// -----------------------------------------------------------
{
+103 -88
View File
@@ -28,9 +28,9 @@ func bindRecordCrudApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
subGroup := rg.Group("/collections/{collection}/records").Unbind(DefaultRateLimitMiddlewareId)
subGroup.GET("", recordsList)
subGroup.GET("/{id}", recordView)
subGroup.POST("", recordCreate(nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.PATCH("/{id}", recordUpdate(nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.DELETE("/{id}", recordDelete(nil))
subGroup.POST("", recordCreate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.PATCH("/{id}", recordUpdate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.DELETE("/{id}", recordDelete(true, nil))
}
func recordsList(e *core.RequestEvent) error {
@@ -79,6 +79,11 @@ func recordsList(e *core.RequestEvent) error {
searchProvider := search.NewProvider(fieldsResolver).Query(query)
// use rowid when available to minimize the need of a covering index with the "id" field
if !collection.IsView() {
searchProvider.CountCol("_rowid_")
}
records := []*core.Record{}
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
if err != nil {
@@ -116,7 +121,9 @@ func recordsList(e *core.RequestEvent) error {
randomizedThrottle(150)
}
return e.JSON(http.StatusOK, e.Result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Result)
})
})
}
@@ -187,11 +194,13 @@ func recordView(e *core.RequestEvent) error {
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
return e.JSON(http.StatusOK, e.Record)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
})
}
func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordCreate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -251,6 +260,73 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
// check the request and record data against the create and manage rules
if !hasSuperuserAuth && collection.CreateRule != nil {
dummyRecord := record.Clone()
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
// set an id if it doesn't have already
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
if dummyRecord.Id == "" {
dummyRecord.Id = "__temp_id__" + dummyRandomPart
}
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
dummyRecord.SetVerified(false)
// export the dummy record data into db params
dummyExport, err := dummyRecord.DBExport(e.App)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
}
dummyParams := make(dbx.Params, len(dummyExport))
selects := make([]string, 0, len(dummyExport))
var param string
for k, v := range dummyExport {
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
param = "__pb_create__" + k
dummyParams[param] = v
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
}
// shallow clone the current collection
dummyCollection := *collection
dummyCollection.Id += dummyRandomPart
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
// check non-empty create rule
if *dummyCollection.CreateRule != "" {
ruleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
}
ruleQuery.AndWhere(expr)
resolver.UpdateQuery(ruleQuery)
var exists int
err = ruleQuery.Limit(1).Row(&exists)
if err != nil || exists == 0 {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
}
}
// check for manage rule access
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
form.GrantManagerAccess()
}
}
var isOptFinalizerCalled bool
event := new(core.RecordRequestEvent)
@@ -262,73 +338,6 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
form.SetApp(e.App)
form.SetRecord(e.Record)
// temporary save the record and check it against the create and manage rules
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
dummyRecord := e.Record.Clone()
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
// set an id if it doesn't have already
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
if dummyRecord.Id == "" {
dummyRecord.Id = "__temp_id__" + dummyRandomPart
}
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
dummyRecord.SetVerified(false)
// export the dummy record data into db params
dummyExport, err := dummyRecord.DBExport(e.App)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
}
dummyParams := make(dbx.Params, len(dummyExport))
selects := make([]string, 0, len(dummyExport))
var param string
for k, v := range dummyExport {
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
param = "__pb_create__" + k
dummyParams[param] = v
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
}
// shallow clone the current collection
dummyCollection := *e.Collection
dummyCollection.Id += dummyRandomPart
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
// check non-empty create rule
if *dummyCollection.CreateRule != "" {
ruleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
}
ruleQuery.AndWhere(expr)
resolver.UpdateQuery(ruleQuery)
var exists bool
err = ruleQuery.Limit(1).Row(&exists)
if err != nil || !exists {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
}
}
// check for manage rule access
manageRuleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
form.GrantManagerAccess()
}
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to create record", err))
@@ -339,7 +348,9 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
err = e.JSON(http.StatusOK, e.Record)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
if err != nil {
return err
}
@@ -369,7 +380,7 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordUpdate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -441,6 +452,14 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
form.Load(data)
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").From(collection.Name).AndWhere(dbx.HashExp{
collection.Name + ".id": record.Id,
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, collection, manageRuleQuery) {
form.GrantManagerAccess()
}
var isOptFinalizerCalled bool
event := new(core.RecordRequestEvent)
@@ -452,14 +471,6 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
form.SetApp(e.App)
form.SetRecord(e.Record)
manageRuleQuery := e.App.DB().Select("(1)").From(e.Collection.Name).AndWhere(dbx.HashExp{
e.Collection.Name + ".id": e.Record.Id,
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
form.GrantManagerAccess()
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to update record.", err))
@@ -470,7 +481,9 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
err = e.JSON(http.StatusOK, e.Record)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
if err != nil {
return err
}
@@ -500,7 +513,7 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordDelete(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -560,7 +573,9 @@ func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err))
}
err = e.NoContent(http.StatusNoContent)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
if err != nil {
return err
}
@@ -719,9 +734,9 @@ func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection
resolver.UpdateQuery(query)
var exists bool
var exists int
err = query.Limit(1).Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
+106 -42
View File
@@ -2,7 +2,6 @@ package apis_test
import (
"bytes"
"errors"
"net/http"
"net/url"
"os"
@@ -418,6 +417,32 @@ func TestRecordCrudList(t *testing.T) {
"OnRecordsListRequest": 1,
},
},
{
Name: "OnRecordsListRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/demo4/records",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordsListRequest().BindFunc(func(e *core.RecordsListRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
// -----------------------------------------------------------
@@ -862,6 +887,32 @@ func TestRecordCrudView(t *testing.T) {
"OnRecordEnrich": 7,
},
},
{
Name: "OnRecordViewRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/demo1/records/al1h9ijdeojtsjy",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordViewRequest().BindFunc(func(e *core.RecordRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
// -----------------------------------------------------------
@@ -1209,7 +1260,7 @@ func TestRecordCrudDelete(t *testing.T) {
},
},
{
Name: "OnRecordAfterDeleteSuccessRequest error response",
Name: "OnRecordDeleteRequest tx body write check",
Method: http.MethodDelete,
URL: "/api/collections/clients/records/o1y0dd0spd786md",
Headers: map[string]string{
@@ -1217,15 +1268,22 @@ func TestRecordCrudDelete(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordDeleteRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordDeleteRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "authenticated record that match the collection delete rule",
@@ -1563,10 +1621,7 @@ func TestRecordCrudCreate(t *testing.T) {
Body: strings.NewReader(`{"title":"test123"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule failure check)",
@@ -1579,10 +1634,7 @@ func TestRecordCrudCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule pass check) + expand relations",
@@ -1731,10 +1783,7 @@ func TestRecordCrudCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.body rule",
@@ -1801,21 +1850,31 @@ func TestRecordCrudCreate(t *testing.T) {
},
},
{
Name: "OnRecordAfterCreateSuccessRequest error response",
Name: "OnRecordCreateRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/demo2/records",
Body: strings.NewReader(`{"title":"new"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordCreateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// ID checks
@@ -1974,14 +2033,9 @@ func TestRecordCrudCreate(t *testing.T) {
"total+":4,
"total-":2
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "@request.body.field with compute modifers (rule pass check)",
@@ -2813,21 +2867,31 @@ func TestRecordCrudUpdate(t *testing.T) {
},
},
{
Name: "OnRecordAfterUpdateSuccessRequest error response",
Name: "OnRecordUpdateRequest tx body write check",
Method: http.MethodPatch,
URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{"title":"new"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordUpdateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordUpdateRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "try to change the id of an existing record",
+28 -5
View File
@@ -129,7 +129,9 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
result.Meta = e.Meta
}
return e.JSON(http.StatusOK, result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, result)
})
})
}
@@ -146,7 +148,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
return true, err
}
var exists bool
var exists int
query := e.App.RecordQuery(record.Collection()).
Select("(1)").
@@ -165,7 +167,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
return true, err
}
return exists, nil
return exists > 0, nil
}
// checkMFA handles any MFA auth checks that needs to be performed for the specified request event.
@@ -455,8 +457,8 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
managedIds := []string{}
query := app.RecordQuery(collection).
Select(app.DB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(app.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
Select(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
expr, err := search.FilterData(*collection.ManageRule).BuildExpr(resolver)
@@ -535,6 +537,27 @@ func firstApiError(errs ...error) *router.ApiError {
return router.NewInternalServerError("", errors.Join(errs...))
}
// execAfterSuccessTx ensures that fn is executed only after a succesul transaction.
//
// If the current app instance is not a transactional or checkTx is false,
// then fn is directly executed.
//
// It could be usually used to allow propagating an error or writing
// custom response from within the wrapped transaction block.
func execAfterSuccessTx(checkTx bool, app core.App, fn func() error) error {
if txInfo := app.TxInfo(); txInfo != nil && checkTx {
txInfo.OnComplete(func(txErr error) error {
if txErr == nil {
return fn()
}
return nil
})
return nil
}
return fn()
}
// -------------------------------------------------------------------
const maxAuthOrigins = 5
+20 -15
View File
@@ -86,7 +86,7 @@ func Serve(app core.App, config ServeConfig) error {
// add a default CSP
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
}
return e.Next()
@@ -143,9 +143,9 @@ func Serve(app core.App, config ServeConfig) error {
NextProtos: []string{acme.ALPNProto},
},
// higher defaults to accommodate large file uploads/downloads
WriteTimeout: 3 * time.Minute,
ReadTimeout: 3 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute,
ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 1 * time.Minute,
Addr: mainAddr,
BaseContext: func(l net.Listener) context.Context {
return baseCtx
@@ -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())
+62 -22
View File
@@ -45,6 +45,12 @@ type App interface {
// IsTransactional checks if the current app instance is part of a transaction.
IsTransactional() bool
// TxInfo returns the transaction associated with the current app instance (if any).
//
// Could be used if you want to execute indirectly a function after
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
TxInfo() *TxAppInfo
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
@@ -91,7 +97,7 @@ type App interface {
// after you are done working with it.
NewFilesystem() (*filesystem.System, error)
// NewFilesystem creates a new local or S3 filesystem instance
// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
@@ -140,46 +146,82 @@ type App interface {
// DB methods
// ---------------------------------------------------------------
// DB returns the default app data db instance (pb_data/data.db).
// DB returns the default app data.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything else
// to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly ConcurrentDB() or NonconcurrentDB().
DB() dbx.Builder
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
// ConcurrentDB returns the concurrent app data.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
ConcurrentDB() dbx.Builder
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
NonconcurrentDB() dbx.Builder
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
// AuxDB returns the app auxiliary.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything else
// to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
AuxDB() dbx.Builder
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db)..
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
AuxConcurrentDB() dbx.Builder
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
AuxNonconcurrentDB() dbx.Builder
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
// in the current app.DB() instance.
// in the data.db.
HasTable(tableName string) bool
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
// in the current app.AuxDB() instance.
// in the auxiliary.db.
AuxHasTable(tableName string) bool
// TableColumns returns all column names of a single table by its name.
@@ -225,21 +267,19 @@ type App interface {
// FindRecordByViewFile returns the original Record of the provided view collection file.
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
// Vacuum executes VACUUM on the current app.DB() instance
// in order to reclaim unused data db disk space.
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
Vacuum() error
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
// in order to reclaim unused auxiliary db disk space.
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
AuxVacuum() error
// ---------------------------------------------------------------
// ModelQuery creates a new preconfigured select app.DB() query with preset
// ModelQuery creates a new preconfigured select data.db query with preset
// SELECT, FROM and other common fields based on the provided model.
ModelQuery(model Model) *dbx.SelectQuery
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
// SELECT, FROM and other common fields based on the provided model.
AuxModelQuery(model Model) *dbx.SelectQuery
@@ -323,10 +363,10 @@ type App interface {
// FindLogById finds a single Log entry by its id.
FindLogById(id string) (*Log, error)
// LogsStatsItem defines the total number of logs for a specific time period.
// LogsStatsItem returns hourly grouped logs statistics.
LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error)
// DeleteOldLogs delete all requests that are created before createdBefore.
// DeleteOldLogs delete all logs that are created before createdBefore.
DeleteOldLogs(createdBefore time.Time) error
// ---------------------------------------------------------------
+155 -43
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())
})
}
@@ -1139,26 +1207,25 @@ func (app *BaseApp) initDataDB() error {
return nil
}
var sqlLogReplacements = map[string]string{
"{{": "`",
"}}": "`",
"[[": "`",
"]]": "`",
"<nil>": "NULL",
var sqlLogReplacements = []struct {
pattern *regexp.Regexp
replacement string
}{
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
{regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
{regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
{regexp.MustCompile(`<nil>`), "NULL"},
}
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`)
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`)
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
// The query is still not suitable for execution and should be used only for log and debug purposes
// (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string {
sql = sqlLogPrefixedTableIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
sql = sqlLogPrefixedColumnIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
for old, new := range sqlLogReplacements {
sql = strings.ReplaceAll(sql, old, new)
for _, item := range sqlLogReplacements {
sql = item.pattern.ReplaceAllString(sql, item.replacement)
}
return sql
@@ -1191,6 +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()
@@ -1207,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()
@@ -1255,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,
+5 -5
View File
@@ -762,9 +762,9 @@ func (c *Collection) updateGeneratedIdIfExists(app App) {
// add a number to the current id (if already exists)
for i := 2; i < 1000; i++ {
var exists bool
var exists int
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
if !exists {
if exists == 0 {
break
}
newId = c.idChecksum() + strconv.Itoa(i)
@@ -989,7 +989,7 @@ func (c *Collection) initTokenKeyField() {
}
// ensure that there is a unique index for the field
if !dbutils.HasSingleColumnUniqueIndex(FieldNameTokenKey, c.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
c.fieldIndexName(FieldNameTokenKey),
@@ -1015,7 +1015,7 @@ func (c *Collection) initEmailField() {
}
// ensure that there is a unique index for the email field
if !dbutils.HasSingleColumnUniqueIndex(FieldNameEmail, c.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
c.fieldIndexName(FieldNameEmail),
@@ -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 {
+279 -1
View File
@@ -1,9 +1,12 @@
package core_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"testing"
@@ -11,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -976,6 +980,280 @@ func TestCollectionDelete(t *testing.T) {
}
}
func TestCollectionModelEventSync(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
testCollections := make([]*core.Collection, 4)
for i := 0; i < 4; i++ {
testCollections[i] = core.NewBaseCollection("sync_test_" + strconv.Itoa(i))
if err := app.Save(testCollections[i]); err != nil {
t.Fatal(err)
}
}
createModelEvent := func() *core.ModelEvent {
event := new(core.ModelEvent)
event.App = app
event.Context = context.Background()
event.Type = "test_a"
event.Model = testCollections[0]
return event
}
createModelErrorEvent := func() *core.ModelErrorEvent {
event := new(core.ModelErrorEvent)
event.ModelEvent = *createModelEvent()
event.Error = errors.New("error_a")
return event
}
changeCollectionEventBefore := func(e *core.CollectionEvent) {
e.Type = "test_b"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 123)
e.Collection = testCollections[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testCollections[2]
}
changeCollectionEventAfter := func(e *core.CollectionEvent) {
e.Type = "test_d"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 789)
e.Collection = testCollections[3]
}
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Model.PK() != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Model.PK(), 0)
}
}
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_d" {
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
}
if v := e.Context.Value("test"); v != 789 {
t.Fatalf("Expected context value %v, got %v", 789, v)
}
if e.Model.PK() != testCollections[3].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[3].Id, e.Model.PK(), 3)
}
}
expectedBeforeCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Collection.Id != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Collection.Id, 0)
}
}
expectedAfterCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_c" {
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
}
if v := e.Context.Value("test"); v != 456 {
t.Fatalf("Expected context value %v, got %v", 456, v)
}
if e.Collection.Id != testCollections[2].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[2].Id, e.Collection.Id, 2)
}
}
modelEventFinalizer := func(e *core.ModelEvent) error {
modelEventFinalizerChange(e)
return nil
}
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
modelEventFinalizerChange(&e.ModelEvent)
e.Error = errors.New("error_c")
return nil
}
modelEventHandler := &hook.Handler[*core.ModelEvent]{
Priority: -999,
Func: func(e *core.ModelEvent) error {
t.Run("before model", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, e)
})
_ = e.Next()
t.Run("after model", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, e)
})
return nil
},
}
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
Priority: -999,
Func: func(e *core.ModelErrorEvent) error {
t.Run("before model error", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_a", v)
}
})
_ = e.Next()
t.Run("after model error", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_d" {
t.Fatalf("Expected error %q, got %q", "error_d", v)
}
})
return nil
},
}
recordEventHandler := &hook.Handler[*core.CollectionEvent]{
Priority: -999,
Func: func(e *core.CollectionEvent) error {
t.Run("before collection", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, e)
})
changeCollectionEventBefore(e)
_ = e.Next()
t.Run("after collection", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, e)
})
changeCollectionEventAfter(e)
return nil
},
}
collectionErrorEventHandler := &hook.Handler[*core.CollectionErrorEvent]{
Priority: -999,
Func: func(e *core.CollectionErrorEvent) error {
t.Run("before collection error", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventBefore(&e.CollectionEvent)
e.Error = errors.New("error_b")
_ = e.Next()
t.Run("after collection error", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_c" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventAfter(&e.CollectionEvent)
e.Error = errors.New("error_d")
return nil
},
}
// OnModelValidate
app.OnCollectionValidate().Bind(recordEventHandler)
app.OnModelValidate().Bind(modelEventHandler)
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreate
app.OnCollectionCreate().Bind(recordEventHandler)
app.OnModelCreate().Bind(modelEventHandler)
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreateExecute
app.OnCollectionCreateExecute().Bind(recordEventHandler)
app.OnModelCreateExecute().Bind(modelEventHandler)
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateSuccess
app.OnCollectionAfterCreateSuccess().Bind(recordEventHandler)
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateError
app.OnCollectionAfterCreateError().Bind(collectionErrorEventHandler)
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelUpdate
app.OnCollectionUpdate().Bind(recordEventHandler)
app.OnModelUpdate().Bind(modelEventHandler)
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelUpdateExecute
app.OnCollectionUpdateExecute().Bind(recordEventHandler)
app.OnModelUpdateExecute().Bind(modelEventHandler)
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateSuccess
app.OnCollectionAfterUpdateSuccess().Bind(recordEventHandler)
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateError
app.OnCollectionAfterUpdateError().Bind(collectionErrorEventHandler)
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelDelete
app.OnCollectionDelete().Bind(recordEventHandler)
app.OnModelDelete().Bind(modelEventHandler)
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelDeleteExecute
app.OnCollectionDeleteExecute().Bind(recordEventHandler)
app.OnModelDeleteExecute().Bind(modelEventHandler)
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteSuccess
app.OnCollectionAfterDeleteSuccess().Bind(recordEventHandler)
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteError
app.OnCollectionAfterDeleteError().Bind(collectionErrorEventHandler)
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
}
func TestCollectionSaveModel(t *testing.T) {
t.Parallel()
@@ -1345,7 +1623,7 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
var sql string
rowErr := app.DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
rowErr := app.ConcurrentDB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
Bind(dbx.Params{"name": viewName}).
Row(&sql)
if rowErr != nil {
+4 -4
View File
@@ -38,7 +38,7 @@ func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection
q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
}
err := q.OrderBy("created ASC").All(&collections)
err := q.OrderBy("rowid ASC").All(&collections)
if err != nil {
return nil, err
}
@@ -206,9 +206,9 @@ func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bo
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
var total int
return query.Row(&exists) == nil && !exists
return query.Row(&total) == nil && total == 0
}
// TruncateCollection deletes all records associated with the provided collection.
@@ -220,7 +220,7 @@ func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bo
// cascade and file delete actions.
func (app *BaseApp) TruncateCollection(collection *Collection) error {
if collection.IsView() {
return errors.New("view collections cannot be truncated since they don't store their own records.")
return errors.New("view collections cannot be truncated since they don't store their own records")
}
return app.RunInTransaction(func(txApp App) error {
+2 -2
View File
@@ -153,7 +153,7 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
defer app.Cleanup()
totalQueries := 0
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
@@ -272,7 +272,7 @@ func TestFindCachedCollectionReferences(t *testing.T) {
}
totalQueries := 0
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
+3 -2
View File
@@ -144,7 +144,7 @@ func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollecti
// run optimize per the SQLite recommendations
// (https://www.sqlite.org/pragma.html#pragma_optimize)
_, optimizeErr := app.DB().NewQuery("PRAGMA optimize").Execute()
_, optimizeErr := app.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
}
}
+9 -5
View File
@@ -34,7 +34,11 @@ func onCollectionValidate(e *CollectionEvent) error {
original,
)
return validator.run()
if err := validator.run(); err != nil {
return err
}
return e.Next()
}
func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator {
@@ -83,7 +87,7 @@ func (validator *collectionValidator) run() error {
validator.original.IsNew(),
validation.Length(1, 100),
validation.Match(DefaultIdRegex),
validation.By(validators.UniqueId(validator.app.DB(), validator.new.TableName())),
validation.By(validators.UniqueId(validator.app.ConcurrentDB(), validator.new.TableName())),
).Else(
validation.By(validators.Equal(validator.original.Id)),
),
@@ -456,7 +460,7 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
SetParams(map[string]any{"fieldName": name})
}
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
SetParams(map[string]any{"fieldName": name})
}
@@ -554,7 +558,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
// ensure that the index name is not used in another collection
var usedTblName string
_ = cv.app.DB().Select("tbl_name").
_ = cv.app.ConcurrentDB().Select("tbl_name").
From("sqlite_master").
AndWhere(dbx.HashExp{"type": "index"}).
AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})).
@@ -666,7 +670,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
if cv.new.IsAuth() {
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
for _, name := range requiredNames {
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
return validation.NewError(
"validation_missing_required_unique_index",
`Missing required unique index for field "{{.fieldName}}".`,
+11 -12
View File
@@ -62,16 +62,16 @@ func crc32Checksum(str string) string {
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
}
// ModelQuery creates a new preconfigured select app.DB() query with preset
// ModelQuery creates a new preconfigured select data.db query with preset
// SELECT, FROM and other common fields based on the provided model.
func (app *BaseApp) ModelQuery(m Model) *dbx.SelectQuery {
return app.modelQuery(app.DB(), m)
return app.modelQuery(app.ConcurrentDB(), m)
}
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
// SELECT, FROM and other common fields based on the provided model.
func (app *BaseApp) AuxModelQuery(m Model) *dbx.SelectQuery {
return app.modelQuery(app.AuxDB(), m)
return app.modelQuery(app.AuxConcurrentDB(), m)
}
func (app *BaseApp) modelQuery(db dbx.Builder, m Model) *dbx.SelectQuery {
@@ -116,8 +116,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
deleteErr := app.OnModelDelete().Trigger(event, func(e *ModelEvent) error {
pk := cast.ToString(e.Model.LastSavedPK())
if cast.ToString(pk) == "" {
if pk == "" {
return errors.New("the model can be deleted only if it is existing and has a non-empty primary key")
}
@@ -152,7 +151,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -343,7 +342,7 @@ func (app *BaseApp) create(ctx context.Context, model Model, withValidations boo
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -427,7 +426,7 @@ func (app *BaseApp) update(ctx context.Context, model Model, withValidations boo
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -483,15 +482,15 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
return validation.NewError("validation_invalid_collection", "Missing or invalid collection.")
}
var exists bool
var exists int
rowErr := app.DB().Select("(1)").
rowErr := app.ConcurrentDB().Select("(1)").
From(collection.Name).
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
Row(&exists)
if rowErr != nil || !exists {
if rowErr != nil || exists == 0 {
return validation.NewError("validation_invalid_record", "Missing or invalid record.")
}
+187
View File
@@ -0,0 +1,187 @@
package core
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/pocketbase/dbx"
)
var _ dbx.Builder = (*dualDBBuilder)(nil)
// note: expects both builder to use the same driver
type dualDBBuilder struct {
concurrentDB dbx.Builder
nonconcurrentDB dbx.Builder
}
// Select implements the [dbx.Builder.Select] interface method.
func (b *dualDBBuilder) Select(cols ...string) *dbx.SelectQuery {
return b.concurrentDB.Select(cols...)
}
// Model implements the [dbx.Builder.Model] interface method.
func (b *dualDBBuilder) Model(data interface{}) *dbx.ModelQuery {
return b.nonconcurrentDB.Model(data)
}
// GeneratePlaceholder implements the [dbx.Builder.GeneratePlaceholder] interface method.
func (b *dualDBBuilder) GeneratePlaceholder(i int) string {
return b.concurrentDB.GeneratePlaceholder(i)
}
// Quote implements the [dbx.Builder.Quote] interface method.
func (b *dualDBBuilder) Quote(str string) string {
return b.concurrentDB.Quote(str)
}
// QuoteSimpleTableName implements the [dbx.Builder.QuoteSimpleTableName] interface method.
func (b *dualDBBuilder) QuoteSimpleTableName(table string) string {
return b.concurrentDB.QuoteSimpleTableName(table)
}
// QuoteSimpleColumnName implements the [dbx.Builder.QuoteSimpleColumnName] interface method.
func (b *dualDBBuilder) QuoteSimpleColumnName(col string) string {
return b.concurrentDB.QuoteSimpleColumnName(col)
}
// QueryBuilder implements the [dbx.Builder.QueryBuilder] interface method.
func (b *dualDBBuilder) QueryBuilder() dbx.QueryBuilder {
return b.concurrentDB.QueryBuilder()
}
// Insert implements the [dbx.Builder.Insert] interface method.
func (b *dualDBBuilder) Insert(table string, cols dbx.Params) *dbx.Query {
return b.nonconcurrentDB.Insert(table, cols)
}
// Upsert implements the [dbx.Builder.Upsert] interface method.
func (b *dualDBBuilder) Upsert(table string, cols dbx.Params, constraints ...string) *dbx.Query {
return b.nonconcurrentDB.Upsert(table, cols, constraints...)
}
// Update implements the [dbx.Builder.Update] interface method.
func (b *dualDBBuilder) Update(table string, cols dbx.Params, where dbx.Expression) *dbx.Query {
return b.nonconcurrentDB.Update(table, cols, where)
}
// Delete implements the [dbx.Builder.Delete] interface method.
func (b *dualDBBuilder) Delete(table string, where dbx.Expression) *dbx.Query {
return b.nonconcurrentDB.Delete(table, where)
}
// CreateTable implements the [dbx.Builder.CreateTable] interface method.
func (b *dualDBBuilder) CreateTable(table string, cols map[string]string, options ...string) *dbx.Query {
return b.nonconcurrentDB.CreateTable(table, cols, options...)
}
// RenameTable implements the [dbx.Builder.RenameTable] interface method.
func (b *dualDBBuilder) RenameTable(oldName, newName string) *dbx.Query {
return b.nonconcurrentDB.RenameTable(oldName, newName)
}
// DropTable implements the [dbx.Builder.DropTable] interface method.
func (b *dualDBBuilder) DropTable(table string) *dbx.Query {
return b.nonconcurrentDB.DropTable(table)
}
// TruncateTable implements the [dbx.Builder.TruncateTable] interface method.
func (b *dualDBBuilder) TruncateTable(table string) *dbx.Query {
return b.nonconcurrentDB.TruncateTable(table)
}
// AddColumn implements the [dbx.Builder.AddColumn] interface method.
func (b *dualDBBuilder) AddColumn(table, col, typ string) *dbx.Query {
return b.nonconcurrentDB.AddColumn(table, col, typ)
}
// DropColumn implements the [dbx.Builder.DropColumn] interface method.
func (b *dualDBBuilder) DropColumn(table, col string) *dbx.Query {
return b.nonconcurrentDB.DropColumn(table, col)
}
// RenameColumn implements the [dbx.Builder.RenameColumn] interface method.
func (b *dualDBBuilder) RenameColumn(table, oldName, newName string) *dbx.Query {
return b.nonconcurrentDB.RenameColumn(table, oldName, newName)
}
// AlterColumn implements the [dbx.Builder.AlterColumn] interface method.
func (b *dualDBBuilder) AlterColumn(table, col, typ string) *dbx.Query {
return b.nonconcurrentDB.AlterColumn(table, col, typ)
}
// AddPrimaryKey implements the [dbx.Builder.AddPrimaryKey] interface method.
func (b *dualDBBuilder) AddPrimaryKey(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.AddPrimaryKey(table, name, cols...)
}
// DropPrimaryKey implements the [dbx.Builder.DropPrimaryKey] interface method.
func (b *dualDBBuilder) DropPrimaryKey(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropPrimaryKey(table, name)
}
// AddForeignKey implements the [dbx.Builder.AddForeignKey] interface method.
func (b *dualDBBuilder) AddForeignKey(table, name string, cols, refCols []string, refTable string, options ...string) *dbx.Query {
return b.nonconcurrentDB.AddForeignKey(table, name, cols, refCols, refTable, options...)
}
// DropForeignKey implements the [dbx.Builder.DropForeignKey] interface method.
func (b *dualDBBuilder) DropForeignKey(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropForeignKey(table, name)
}
// CreateIndex implements the [dbx.Builder.CreateIndex] interface method.
func (b *dualDBBuilder) CreateIndex(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.CreateIndex(table, name, cols...)
}
// CreateUniqueIndex implements the [dbx.Builder.CreateUniqueIndex] interface method.
func (b *dualDBBuilder) CreateUniqueIndex(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.CreateUniqueIndex(table, name, cols...)
}
// DropIndex implements the [dbx.Builder.DropIndex] interface method.
func (b *dualDBBuilder) DropIndex(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropIndex(table, name)
}
// NewQuery implements the [dbx.Builder.NewQuery] interface method by
// routing the SELECT queries to the concurrent builder instance.
func (b *dualDBBuilder) NewQuery(str string) *dbx.Query {
// note: technically INSERT/UPDATE/DELETE could also have CTE but since
// it is rare for now this scase is ignored to avoid unnecessary complicating the checks
trimmed := trimLeftSpaces(str)
if hasPrefixFold(trimmed, "SELECT") || hasPrefixFold(trimmed, "WITH") {
return b.concurrentDB.NewQuery(str)
}
return b.nonconcurrentDB.NewQuery(str)
}
var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}
// note: similar to strings.Space() but without the right trim because it is not needed in our case
func trimLeftSpaces(str string) string {
start := 0
for ; start < len(str); start++ {
c := str[start]
if c >= utf8.RuneSelf {
return strings.TrimLeftFunc(str[start:], unicode.IsSpace)
}
if asciiSpace[c] == 0 {
break
}
}
return str[start:]
}
// note: the prefix is expected to be ASCII
func hasPrefixFold(str, prefix string) bool {
if len(str) < len(prefix) {
return false
}
return strings.EqualFold(str[:len(prefix)], prefix)
}
+14 -16
View File
@@ -11,7 +11,7 @@ import (
func (app *BaseApp) TableColumns(tableName string) ([]string, error) {
columns := []string{}
err := app.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
err := app.ConcurrentDB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
Column(&columns)
@@ -34,7 +34,7 @@ type TableInfoRow struct {
func (app *BaseApp) TableInfo(tableName string) ([]*TableInfoRow, error) {
info := []*TableInfoRow{}
err := app.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
err := app.ConcurrentDB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
All(&info)
if err != nil {
@@ -59,7 +59,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
Sql string
}{}
err := app.DB().Select("name", "sql").
err := app.ConcurrentDB().Select("name", "sql").
From("sqlite_master").
AndWhere(dbx.NewExp("sql is not null")).
AndWhere(dbx.HashExp{
@@ -87,7 +87,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
// NB! Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
func (app *BaseApp) DeleteTable(tableName string) error {
_, err := app.DB().NewQuery(fmt.Sprintf(
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}",
tableName,
)).Execute()
@@ -96,19 +96,19 @@ func (app *BaseApp) DeleteTable(tableName string) error {
}
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
// in the current app.DB() instance.
// in the data.db.
func (app *BaseApp) HasTable(tableName string) bool {
return app.hasTable(app.DB(), tableName)
return app.hasTable(app.ConcurrentDB(), tableName)
}
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
// in the current app.AuxDB() instance.
// in the auixiliary.db.
func (app *BaseApp) AuxHasTable(tableName string) bool {
return app.hasTable(app.AuxDB(), tableName)
return app.hasTable(app.AuxConcurrentDB(), tableName)
}
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
var exists bool
var exists int
err := db.Select("(1)").
From("sqlite_schema").
@@ -117,19 +117,17 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
Limit(1).
Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
// Vacuum executes VACUUM on the current app.DB() instance
// in order to reclaim unused data db disk space.
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
func (app *BaseApp) Vacuum() error {
return app.vacuum(app.DB())
return app.vacuum(app.NonconcurrentDB())
}
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
// in order to reclaim unused auxiliary db disk space.
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
func (app *BaseApp) AuxVacuum() error {
return app.vacuum(app.AuxDB())
return app.vacuum(app.AuxNonconcurrentDB())
}
func (app *BaseApp) vacuum(db dbx.Builder) error {
+4 -4
View File
@@ -202,10 +202,10 @@ func TestVacuum(t *testing.T) {
defer app.Cleanup()
calledQueries := []string{}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
@@ -229,10 +229,10 @@ func TestAuxVacuum(t *testing.T) {
defer app.Cleanup()
calledQueries := []string{}
app.AuxDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.AuxDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
+4 -4
View File
@@ -38,12 +38,12 @@ func TestModelQuery(t *testing.T) {
modelsQuery := app.ModelQuery(&core.Collection{})
logsModelQuery := app.AuxModelQuery(&core.Collection{})
if app.DB() == modelsQuery.Info().Builder {
t.Fatalf("ModelQuery() is not using app.DB()")
if app.ConcurrentDB() == modelsQuery.Info().Builder {
t.Fatalf("ModelQuery() is not using app.ConcurrentDB()")
}
if app.AuxDB() == logsModelQuery.Info().Builder {
t.Fatalf("AuxModelQuery() is not using app.AuxDB()")
if app.AuxConcurrentDB() == logsModelQuery.Info().Builder {
t.Fatalf("AuxModelQuery() is not using app.AuxConcurrentDB()")
}
expectedSQL := "SELECT {{_collections}}.* FROM `_collections`"
+12 -5
View File
@@ -60,7 +60,7 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
clone.nonconcurrentDB = tx
}
clone.txInfo = &txAppInfo{
clone.txInfo = &TxAppInfo{
parent: app,
isForAuxDB: isForAuxDB,
}
@@ -68,22 +68,29 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
return &clone
}
type txAppInfo struct {
// TxAppInfo represents an active transaction context associated to an existing app instance.
type TxAppInfo struct {
parent *BaseApp
afterFuncs []func(txErr error) error
mu sync.Mutex
isForAuxDB bool
}
func (a *txAppInfo) onAfterFunc(fn func(txErr error) error) {
// OnComplete registers the provided callback that will be invoked
// once the related transaction ends (either completes successfully or rollbacked with an error).
//
// The callback receives the transaction error (if any) as its argument.
// Any additional errors returned by the OnComplete callbacks will be
// joined together with txErr when returning the final transaction result.
func (a *TxAppInfo) OnComplete(fn func(txErr error) error) {
a.mu.Lock()
defer a.mu.Unlock()
a.afterFuncs = append(a.afterFuncs, fn)
}
// note: can be called only once because txAppInfo is cleared
func (a *txAppInfo) runAfterFuncs(txErr error) error {
// note: can be called only once because TxAppInfo is cleared
func (a *TxAppInfo) runAfterFuncs(txErr error) error {
a.mu.Lock()
defer a.mu.Unlock()
+3 -1
View File
@@ -154,8 +154,10 @@ const (
RequestInfoContextExpand = "expand"
RequestInfoContextRealtime = "realtime"
RequestInfoContextProtectedFile = "protectedFile"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextBatch = "batch"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextOTP = "otp"
RequestInfoContextPasswordAuth = "password"
)
// RequestInfo defines a HTTP request data struct, usually used
+8 -2
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.
@@ -277,7 +283,7 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
me.Error = re.Error
re.Error = me.Error
}
// -------------------------------------------------------------------
@@ -354,7 +360,7 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
me.Error = ce.Error
ce.Error = me.Error
}
// -------------------------------------------------------------------
+1 -1
View File
@@ -155,7 +155,7 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.DB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.ConcurrentDB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
result, err := app.FindFirstExternalAuthByExpr(s.expr)
hasErr := err != nil
+8 -3
View File
@@ -5,11 +5,11 @@ import (
"database/sql/driver"
"errors"
"fmt"
"log"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
@@ -356,8 +356,7 @@ func (f *FileField) Intercept(
return nil
case InterceptorActionAfterCreateError, InterceptorActionAfterUpdateError:
// when in transaction we assume that the error was handled by afterRecordExecuteFailure
_, insideTransaction := app.DB().(*dbx.Tx)
if insideTransaction {
if app.IsTransactional() {
return actionFunc()
}
@@ -637,7 +636,13 @@ func (f *FileField) FindGetter(key string) GetterFunc {
return func(record *Record) any {
return record.GetRaw(f.Name)
}
case f.Name + ":unsaved":
return func(record *Record) any {
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
}
case f.Name + ":uploaded":
// deprecated
log.Println("[file field getter] please replace :uploaded with :unsaved")
return func(record *Record) any {
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
}
+2 -2
View File
@@ -672,8 +672,8 @@ func TestFileFieldFindGetter(t *testing.T) {
`["300_UhLKX91HVb.png",{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
{
"uploaded",
field.GetName() + ":uploaded",
"unsaved",
field.GetName() + ":unsaved",
true,
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
+148
View File
@@ -0,0 +1,148 @@
package core
import (
"context"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/types"
)
func init() {
Fields[FieldTypeGeoPoint] = func() Field {
return &GeoPointField{}
}
}
const FieldTypeGeoPoint = "geoPoint"
var (
_ Field = (*GeoPointField)(nil)
)
// GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates.
//
// You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props.
// The stored value is always converted to [types.GeoPoint].
// Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint].
//
// Examples of updating a record's GeoPointField value programmatically:
//
// record.Set("location", types.GeoPoint{Lat: 123, Lon: 456})
// record.Set("location", map[string]any{"lat":123, "lon":456})
// record.Set("location", []byte(`{"lat":123, "lon":456}`)
type GeoPointField struct {
// Name (required) is the unique name of the field.
Name string `form:"name" json:"name"`
// Id is the unique stable field identifier.
//
// It is automatically generated from the name when adding to a collection FieldsList.
Id string `form:"id" json:"id"`
// System prevents the renaming and removal of the field.
System bool `form:"system" json:"system"`
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Required will require the field coordinates to be non-zero (aka. not "Null Island").
Required bool `form:"required" json:"required"`
}
// Type implements [Field.Type] interface method.
func (f *GeoPointField) Type() string {
return FieldTypeGeoPoint
}
// GetId implements [Field.GetId] interface method.
func (f *GeoPointField) GetId() string {
return f.Id
}
// SetId implements [Field.SetId] interface method.
func (f *GeoPointField) SetId(id string) {
f.Id = id
}
// GetName implements [Field.GetName] interface method.
func (f *GeoPointField) GetName() string {
return f.Name
}
// SetName implements [Field.SetName] interface method.
func (f *GeoPointField) SetName(name string) {
f.Name = name
}
// GetSystem implements [Field.GetSystem] interface method.
func (f *GeoPointField) GetSystem() bool {
return f.System
}
// SetSystem implements [Field.SetSystem] interface method.
func (f *GeoPointField) SetSystem(system bool) {
f.System = system
}
// GetHidden implements [Field.GetHidden] interface method.
func (f *GeoPointField) GetHidden() bool {
return f.Hidden
}
// SetHidden implements [Field.SetHidden] interface method.
func (f *GeoPointField) SetHidden(hidden bool) {
f.Hidden = hidden
}
// ColumnType implements [Field.ColumnType] interface method.
func (f *GeoPointField) ColumnType(app App) string {
return `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
}
// PrepareValue implements [Field.PrepareValue] interface method.
func (f *GeoPointField) PrepareValue(record *Record, raw any) (any, error) {
point := types.GeoPoint{}
err := point.Scan(raw)
return point, err
}
// ValidateValue implements [Field.ValidateValue] interface method.
func (f *GeoPointField) ValidateValue(ctx context.Context, app App, record *Record) error {
val, ok := record.GetRaw(f.Name).(types.GeoPoint)
if !ok {
return validators.ErrUnsupportedValueType
}
// zero value
if val.Lat == 0 && val.Lon == 0 {
if f.Required {
return validation.ErrRequired
}
return nil
}
if val.Lat < -90 || val.Lat > 90 {
return validation.NewError("validation_invalid_latitude", "Latitude must be between -90 and 90 degrees.")
}
if val.Lon < -180 || val.Lon > 180 {
return validation.NewError("validation_invalid_longitude", "Longitude must be between -180 and 180 degrees.")
}
return nil
}
// ValidateSettings implements [Field.ValidateSettings] interface method.
func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
)
}
+202
View File
@@ -0,0 +1,202 @@
package core_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestGeoPointFieldBaseMethods(t *testing.T) {
testFieldBaseMethods(t, core.FieldTypeGeoPoint)
}
func TestGeoPointFieldColumnType(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
f := &core.GeoPointField{}
expected := `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
if v := f.ColumnType(app); v != expected {
t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
}
}
func TestGeoPointFieldPrepareValue(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
f := &core.GeoPointField{}
record := core.NewRecord(core.NewBaseCollection("test"))
scenarios := []struct {
raw any
expected string
}{
{nil, `{"lon":0,"lat":0}`},
{"", `{"lon":0,"lat":0}`},
{[]byte{}, `{"lon":0,"lat":0}`},
{map[string]any{}, `{"lon":0,"lat":0}`},
{types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
{&types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
{[]byte(`{"lon": 10, "lat": 20}`), `{"lon":10,"lat":20}`},
{map[string]any{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
{map[string]float64{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
v, err := f.PrepareValue(record, s.raw)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
if rawStr != s.expected {
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, rawStr)
}
})
}
}
func TestGeoPointFieldValidateValue(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewBaseCollection("test_collection")
scenarios := []struct {
name string
field *core.GeoPointField
record func() *core.Record
expectError bool
}{
{
"invalid raw value",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", 123)
return record
},
true,
},
{
"zero field value (non-required)",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{})
return record
},
false,
},
{
"zero field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{})
return record
},
true,
},
{
"non-zero Lat field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: 1})
return record
},
false,
},
{
"non-zero Lon field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: 1})
return record
},
false,
},
{
"non-zero Lat-Lon field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: -1, Lat: -2})
return record
},
false,
},
{
"lat < -90",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: -90.1})
return record
},
true,
},
{
"lat > 90",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: 90.1})
return record
},
true,
},
{
"lon < -180",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: -180.1})
return record
},
true,
},
{
"lon > 180",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: 180.1})
return record
},
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
err := s.field.ValidateValue(context.Background(), app, s.record())
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}
func TestGeoPointFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
}
+2 -2
View File
@@ -20,7 +20,7 @@ func init() {
const FieldTypeJSON = "json"
const DefaultJSONFieldMaxSize int64 = 5 << 20
const DefaultJSONFieldMaxSize int64 = 1 << 20
var (
_ Field = (*JSONField)(nil)
@@ -53,7 +53,7 @@ type JSONField struct {
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
//
// If zero, a default limit of 5MB is applied.
// If zero, a default limit of 1MB is applied.
MaxSize int64 `form:"maxSize" json:"maxSize"`
// Required will require the field value to be non-empty JSON value
+1 -1
View File
@@ -146,7 +146,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
&core.JSONField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (5<<20))+`"`))
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (1<<20))+`"`))
return record
},
true,
+1 -1
View File
@@ -219,7 +219,7 @@ func (f *RelationField) ValidateValue(ctx context.Context, app App, record *Reco
}
var total int
_ = app.DB().
_ = app.ConcurrentDB().
Select("count(*)").
From(relCollection.Name).
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
+4 -4
View File
@@ -193,14 +193,14 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
//
// (@todo eventually may get replaced in the future with a system unique constraint to avoid races or wrapping the request in a transaction)
if f.Pattern != defaultLowercaseRecordIdPattern {
var exists bool
err := app.DB().
var exists int
err := app.ConcurrentDB().
Select("(1)").
From(record.TableName()).
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": strings.ToLower(newVal)})).
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
Limit(1).
Row(&exists)
if exists || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
if exists > 0 || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
}
}
+4 -4
View File
@@ -55,10 +55,10 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
}
// silence query logs
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.concurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.concurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.nonconcurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.nonconcurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.Settings().Logs.MinLevel = testLogLevel
if err := app.Save(app.Settings()); err != nil {
+2 -2
View File
@@ -34,7 +34,7 @@ type LogsStatsItem struct {
Total int `db:"total" json:"total"`
}
// LogsStats returns hourly grouped requests logs statistics.
// LogsStats returns hourly grouped logs statistics.
func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
result := []*LogsStatsItem{}
@@ -51,7 +51,7 @@ func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
return result, err
}
// DeleteOldLogs delete all requests that are created before createdBefore.
// DeleteOldLogs delete all logs that are created before createdBefore.
//
// For better performance the logs delete is executed as plain SQL statement,
// aka. no delete model hook events will be fired.
+14 -18
View File
@@ -5,9 +5,9 @@ import (
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cast"
)
@@ -80,15 +80,11 @@ func (r *MigrationsRunner) Run(args ...string) error {
return err
}
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf(
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
strings.Join(names, "\n"),
toRevertCount,
),
}
survey.AskOne(prompt, &confirm)
confirm := osutils.YesNoPrompt(fmt.Sprintf(
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
strings.Join(names, "\n"),
toRevertCount,
), false)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
@@ -116,7 +112,7 @@ func (r *MigrationsRunner) Run(args ...string) error {
color.Green("The %s table was synced with the available migrations.", r.tableName)
return nil
default:
return fmt.Errorf("Unsupported command: %q\n", cmd)
return fmt.Errorf("unsupported command: %q", cmd)
}
}
@@ -155,12 +151,12 @@ func (r *MigrationsRunner) Up() ([]string, error) {
// ignore empty Up action
if m.Up != nil {
if err := m.Up(txApp); err != nil {
return fmt.Errorf("Failed to apply migration %s: %w", m.File, err)
return fmt.Errorf("failed to apply migration %s: %w", m.File, err)
}
}
if err := r.saveAppliedMigration(txApp, m.File); err != nil {
return fmt.Errorf("Failed to save applied migration info for %s: %w", m.File, err)
return fmt.Errorf("failed to save applied migration info for %s: %w", m.File, err)
}
applied = append(applied, m.File)
@@ -208,12 +204,12 @@ func (r *MigrationsRunner) Down(toRevertCount int) ([]string, error) {
// ignore empty Down action
if m.Down != nil {
if err := m.Down(txApp); err != nil {
return fmt.Errorf("Failed to revert migration %s: %w", m.File, err)
return fmt.Errorf("failed to revert migration %s: %w", m.File, err)
}
}
if err := r.saveRevertedMigration(txApp, m.File); err != nil {
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.File, err)
return fmt.Errorf("failed to save reverted migration info for %s: %w", m.File, err)
}
reverted = append(reverted, m.File)
@@ -267,15 +263,15 @@ func (r *MigrationsRunner) initMigrationsTable() error {
}
func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool {
var exists bool
var exists int
err := txApp.DB().Select("count(*)").
err := txApp.DB().Select("(1)").
From(r.tableName).
Where(dbx.HashExp{"file": file}).
Limit(1).
Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error {
+3 -28
View File
@@ -200,38 +200,13 @@ func TestMigrationsRunnerRemoveMissingAppliedMigrations(t *testing.T) {
}
func isMigrationApplied(app core.App, file string) bool {
var exists bool
var exists int
err := app.DB().Select("count(*)").
err := app.DB().Select("(1)").
From(core.DefaultMigrationsTable).
Where(dbx.HashExp{"file": file}).
Limit(1).
Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
// // -------------------------------------------------------------------
// type testDB struct {
// *dbx.DB
// CalledQueries []string
// }
// // NB! Don't forget to call `db.Close()` at the end of the test.
// func createTestDB() (*testDB, error) {
// sqlDB, err := sql.Open("sqlite", ":memory:")
// if err != nil {
// return nil, err
// }
// db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")}
// db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
// db.CalledQueries = append(db.CalledQueries, sql)
// }
// db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
// db.CalledQueries = append(db.CalledQueries, sql)
// }
// return &db, nil
// }
+10
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -300,6 +301,13 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
return mapVal(m, keys...)
case mapExtractor:
return mapVal(m.AsMap(), keys...)
case types.JSONRaw:
var raw any
err := json.Unmarshal(m, &raw)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal raw JSON in order extract nested value from: %w", err)
}
return extractNestedVal(raw, keys...)
// slices
case []string:
@@ -338,6 +346,8 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
return arrVal(extracted, keys...)
case []any:
return arrVal(m, keys...)
case []types.JSONRaw:
return arrVal(m, keys...)
default:
return nil, fmt.Errorf("expected map or array, got %#v", rawData)
}
+14 -4
View File
@@ -419,8 +419,9 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("non-filterable field %q", prop)
}
// json field -> treat the rest of the props as json path
if field != nil && field.Type() == FieldTypeJSON {
// json or geoPoint field -> treat the rest of the props as json path
// @todo consider converting to "JSONExtractable" interface with optional extra validation for the remaining props?
if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
var jsonPath strings.Builder
for j, p := range r.activeProps[i+1:] {
if _, err := strconv.Atoi(p); err == nil {
@@ -479,7 +480,11 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
}
return nil, fmt.Errorf("missing back relation field %q", parts[2])
}
if backField.Type() != FieldTypeRelation {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
}
@@ -492,7 +497,11 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
}
if backRelField.CollectionId != collection.Id {
return nil, fmt.Errorf("invalid back relation field %q collection reference", backField.GetName())
// https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("invalid collection reference of a back relation field %q", backField.GetName())
}
// join the back relation to the main query
@@ -505,7 +514,8 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
isBackRelMultiple := backRelField.IsMultiple()
if !isBackRelMultiple {
// additionally check if the rel field has a single column unique index
isBackRelMultiple = !dbutils.HasSingleColumnUniqueIndex(backRelField.Name, backCollection.Indexes)
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
isBackRelMultiple = !hasUniqueIndex
}
if !isBackRelMultiple {
File diff suppressed because one or more lines are too long
+22 -7
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"slices"
"sort"
@@ -14,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"
@@ -968,25 +970,32 @@ func (m *Record) GetDateTime(key string) types.DateTime {
return d
}
// GetGeoPoint returns the data value for "key" as a GeoPoint instance.
func (m *Record) GetGeoPoint(key string) types.GeoPoint {
point := types.GeoPoint{}
_ = point.Scan(m.Get(key))
return point
}
// GetStringSlice returns the data value for "key" as a slice of non-zero unique strings.
func (m *Record) GetStringSlice(key string) []string {
return list.ToUniqueStringSlice(m.Get(key))
}
// GetUploadedFiles returns the uploaded files for the provided "file" field key,
// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
// (aka. the current [*filesytem.File] values) so that you can apply further
// validations or modifications (including changing the file name or content before persisting).
//
// Example:
//
// files := record.GetUploadedFiles("documents")
// files := record.GetUnsavedFiles("documents")
// for _, f := range files {
// f.Name = "doc_" + f.Name // add a prefix to each file name
// }
// app.Save(record) // the files are pointers so the applied changes will transparently reflect on the record value
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
if !strings.HasSuffix(key, ":uploaded") {
key += ":uploaded"
func (m *Record) GetUnsavedFiles(key string) []*filesystem.File {
if !strings.HasSuffix(key, ":unsaved") {
key += ":unsaved"
}
values, _ := m.Get(key).([]*filesystem.File)
@@ -994,6 +1003,12 @@ func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
return values
}
// Deprecated: replaced with GetUnsavedFiles.
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
log.Println("Please replace GetUploadedFiles with GetUnsavedFiles")
return m.GetUnsavedFiles(key)
}
// Retrieves the "key" json field value and unmarshals it into "result".
//
// Example
@@ -1516,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,
})))
+329 -7
View File
@@ -5,9 +5,11 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
@@ -16,6 +18,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -876,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},
}
@@ -1013,7 +1016,44 @@ func TestRecordGetStringSlice(t *testing.T) {
}
}
func TestRecordGetUploadedFiles(t *testing.T) {
func TestRecordGetGeoPoint(t *testing.T) {
t.Parallel()
scenarios := []struct {
value any
expected string
}{
{nil, `{"lon":0,"lat":0}`},
{"", `{"lon":0,"lat":0}`},
{0, `{"lon":0,"lat":0}`},
{false, `{"lon":0,"lat":0}`},
{"{}", `{"lon":0,"lat":0}`},
{"[]", `{"lon":0,"lat":0}`},
{[]int{1, 2}, `{"lon":0,"lat":0}`},
{map[string]any{"lon": 1, "lat": 2}, `{"lon":1,"lat":2}`},
{[]byte(`{"lon":1,"lat":2}`), `{"lon":1,"lat":2}`},
{`{"lon":1,"lat":2}`, `{"lon":1,"lat":2}`},
{types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
{&types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
}
collection := core.NewBaseCollection("test")
record := core.NewRecord(collection)
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
record.Set("test", s.value)
pointStr := record.GetGeoPoint("test").String()
if pointStr != s.expected {
t.Fatalf("Expected %q, got %q", s.expected, pointStr)
}
})
}
}
func TestRecordGetUnsavedFiles(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
@@ -1054,14 +1094,14 @@ func TestRecordGetUploadedFiles(t *testing.T) {
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
{
"files:uploaded",
"files:unsaved",
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) {
v := record.GetUploadedFiles(s.key)
v := record.GetUnsavedFiles(s.key)
raw, err := json.Marshal(v)
if err != nil {
@@ -1618,6 +1658,288 @@ func TestRecordValidate(t *testing.T) {
})
}
func TestRecordModelEventSync(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
testRecords := make([]*core.Record, 4)
for i := 0; i < 4; i++ {
testRecords[i] = core.NewRecord(col)
testRecords[i].Set("title", "sync_test_"+strconv.Itoa(i))
if err := app.Save(testRecords[i]); err != nil {
t.Fatal(err)
}
}
createModelEvent := func() *core.ModelEvent {
event := new(core.ModelEvent)
event.App = app
event.Context = context.Background()
event.Type = "test_a"
event.Model = testRecords[0]
return event
}
createModelErrorEvent := func() *core.ModelErrorEvent {
event := new(core.ModelErrorEvent)
event.ModelEvent = *createModelEvent()
event.Error = errors.New("error_a")
return event
}
changeRecordEventBefore := func(e *core.RecordEvent) {
e.Type = "test_b"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 123)
e.Record = testRecords[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testRecords[2]
}
changeRecordEventAfter := func(e *core.RecordEvent) {
e.Type = "test_d"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 789)
e.Record = testRecords[3]
}
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Model.PK() != testRecords[0].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Model.PK(), 0)
}
}
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_d" {
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
}
if v := e.Context.Value("test"); v != 789 {
t.Fatalf("Expected context value %v, got %v", 789, v)
}
// note: currently the Model and Record values are not synced due to performance consideration
if e.Model.PK() != testRecords[2].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[2].Id, e.Model.PK(), 2)
}
}
expectedBeforeRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Record.Id != testRecords[0].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Record.Id, 2)
}
}
expectedAfterRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
if e.Type != "test_c" {
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
}
if v := e.Context.Value("test"); v != 456 {
t.Fatalf("Expected context value %v, got %v", 456, v)
}
// note: currently the Model and Record values are not synced due to performance consideration
if e.Record.Id != testRecords[1].Id {
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[1].Id, e.Record.Id, 1)
}
}
modelEventFinalizer := func(e *core.ModelEvent) error {
modelEventFinalizerChange(e)
return nil
}
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
modelEventFinalizerChange(&e.ModelEvent)
e.Error = errors.New("error_c")
return nil
}
modelEventHandler := &hook.Handler[*core.ModelEvent]{
Priority: -999,
Func: func(e *core.ModelEvent) error {
t.Run("before model", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, e)
})
_ = e.Next()
t.Run("after model", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, e)
})
return nil
},
}
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
Priority: -999,
Func: func(e *core.ModelErrorEvent) error {
t.Run("before model error", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_a", v)
}
})
_ = e.Next()
t.Run("after model error", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_d" {
t.Fatalf("Expected error %q, got %q", "error_d", v)
}
})
return nil
},
}
recordEventHandler := &hook.Handler[*core.RecordEvent]{
Priority: -999,
Func: func(e *core.RecordEvent) error {
t.Run("before record", func(t *testing.T) {
expectedBeforeRecordEventHandlerChecks(t, e)
})
changeRecordEventBefore(e)
_ = e.Next()
t.Run("after record", func(t *testing.T) {
expectedAfterRecordEventHandlerChecks(t, e)
})
changeRecordEventAfter(e)
return nil
},
}
recordErrorEventHandler := &hook.Handler[*core.RecordErrorEvent]{
Priority: -999,
Func: func(e *core.RecordErrorEvent) error {
t.Run("before record error", func(t *testing.T) {
expectedBeforeRecordEventHandlerChecks(t, &e.RecordEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeRecordEventBefore(&e.RecordEvent)
e.Error = errors.New("error_b")
_ = e.Next()
t.Run("after record error", func(t *testing.T) {
expectedAfterRecordEventHandlerChecks(t, &e.RecordEvent)
if v := e.Error.Error(); v != "error_c" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeRecordEventAfter(&e.RecordEvent)
e.Error = errors.New("error_d")
return nil
},
}
// OnModelValidate
app.OnRecordValidate().Bind(recordEventHandler)
app.OnModelValidate().Bind(modelEventHandler)
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreate
app.OnRecordCreate().Bind(recordEventHandler)
app.OnModelCreate().Bind(modelEventHandler)
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreateExecute
app.OnRecordCreateExecute().Bind(recordEventHandler)
app.OnModelCreateExecute().Bind(modelEventHandler)
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateSuccess
app.OnRecordAfterCreateSuccess().Bind(recordEventHandler)
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateError
app.OnRecordAfterCreateError().Bind(recordErrorEventHandler)
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelUpdate
app.OnRecordUpdate().Bind(recordEventHandler)
app.OnModelUpdate().Bind(modelEventHandler)
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelUpdateExecute
app.OnRecordUpdateExecute().Bind(recordEventHandler)
app.OnModelUpdateExecute().Bind(modelEventHandler)
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateSuccess
app.OnRecordAfterUpdateSuccess().Bind(recordEventHandler)
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateError
app.OnRecordAfterUpdateError().Bind(recordErrorEventHandler)
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelDelete
app.OnRecordDelete().Bind(recordEventHandler)
app.OnModelDelete().Bind(modelEventHandler)
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelDeleteExecute
app.OnRecordDeleteExecute().Bind(recordEventHandler)
app.OnModelDeleteExecute().Bind(modelEventHandler)
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteSuccess
app.OnRecordAfterDeleteSuccess().Bind(recordEventHandler)
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteError
app.OnRecordAfterDeleteError().Bind(recordErrorEventHandler)
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
}
func TestRecordSave(t *testing.T) {
t.Parallel()
@@ -1932,13 +2254,13 @@ func TestRecordDelete(t *testing.T) {
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
rec3, _ := app.FindRecordById("users", "oap640cot4yru2s")
@@ -1958,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)
}
+19 -4
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
@@ -34,7 +35,7 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
tableName = "@@__invalidCollectionModelOrIdentifier"
}
query := app.DB().Select(app.DB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
query := app.ConcurrentDB().Select(app.ConcurrentDB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
// in case of an error attach a new context and cancel it immediately with the error
if collectionErr != nil {
@@ -527,20 +528,34 @@ func (app *BaseApp) FindAuthRecordByToken(token string, validTypes ...string) (*
// FindAuthRecordByEmail finds the auth record associated with the provided email.
//
// The email check would be case-insensitive if the related collection
// email unique index has COLLATE NOCASE specified for the email column.
//
// Returns an error if it is not an auth collection or the record is not found.
func (app *BaseApp) FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error) {
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
if err != nil {
return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
}
if !collection.IsAuth() {
return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
}
record := &Record{}
var expr dbx.Expression
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, FieldNameEmail)
if ok && strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search
expr = dbx.NewExp("[["+FieldNameEmail+"]] = {:email} COLLATE NOCASE", dbx.Params{"email": email})
} else {
expr = dbx.HashExp{FieldNameEmail: email}
}
err = app.RecordQuery(collection).
AndWhere(dbx.HashExp{FieldNameEmail: email}).
AndWhere(expr).
Limit(1).
One(record)
if err != nil {
@@ -584,7 +599,7 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
return true, nil
}
var exists bool
var exists int
query := app.RecordQuery(record.Collection()).
Select("(1)").
@@ -603,5 +618,5 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
return false, err
}
return exists, nil
return exists > 0, nil
}
+2 -2
View File
@@ -103,7 +103,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
// add the related id(s) as a dynamic relation field value to
// allow further expand checks at later stage in a more unified manner
prepErr := func() error {
q := app.DB().Select("id").
q := app.ConcurrentDB().Select("id").
From(indirectRel.Name).
Limit(1000) // the limit is arbitrary chosen and may change in the future
@@ -143,7 +143,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
MaxSelect: 2147483647,
CollectionId: indirectRel.Id,
}
if dbutils.HasSingleColumnUniqueIndex(indirectRelField.GetName(), indirectRel.Indexes) {
if _, ok := dbutils.FindSingleColumnUniqueIndex(indirectRel.Indexes, indirectRelField.GetName()); ok {
relField.MaxSelect = 1
}
relCollection = indirectRel
+33 -9
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -966,23 +967,46 @@ func TestFindAuthRecordByToken(t *testing.T) {
func TestFindAuthRecordByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
email string
nocaseIndex bool
expectError bool
}{
{"missing", "test@example.com", true},
{"demo2", "test@example.com", true},
{"users", "missing@example.com", true},
{"users", "test@example.com", false},
{"clients", "test2@example.com", false},
{"missing", "test@example.com", false, true},
{"demo2", "test@example.com", false, true},
{"users", "missing@example.com", false, true},
{"users", "test@example.com", false, false},
{"clients", "test2@example.com", false, false},
// case-insensitive tests
{"clients", "TeSt2@example.com", false, true},
{"clients", "TeSt2@example.com", true, false},
}
for _, s := range scenarios {
t.Run(fmt.Sprintf("%s_%s", s.collectionIdOrName, s.email), func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.FindCollectionByNameOrId(s.collectionIdOrName)
if collection != nil {
emailIndex, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, core.FieldNameEmail)
if ok {
if s.nocaseIndex {
emailIndex.Columns[0].Collate = "nocase"
} else {
emailIndex.Columns[0].Collate = ""
}
collection.RemoveIndex(emailIndex.IndexName)
collection.Indexes = append(collection.Indexes, emailIndex.Build())
err := app.Save(collection)
if err != nil {
t.Fatalf("Failed to update email index: %v", err)
}
}
}
record, err := app.FindAuthRecordByEmail(s.collectionIdOrName, s.email)
hasErr := err != nil
@@ -994,7 +1018,7 @@ func TestFindAuthRecordByEmail(t *testing.T) {
return
}
if record.Email() != s.email {
if !strings.EqualFold(record.Email(), s.email) {
t.Fatalf("Expected record with email %s, got %s", s.email, record.Email())
}
})
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/tools/security"
)
+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
}
+6 -4
View File
@@ -50,7 +50,6 @@ func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []stri
return err
}
//
if _, ok := err.(validation.Errors); ok {
return err
}
@@ -59,12 +58,15 @@ func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []stri
// check for unique constraint failure
if strings.Contains(msg, "unique constraint failed") {
// note: extra space to unify multi-columns lookup
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ") + " "
normalizedErrs := validation.Errors{}
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
for _, name := range fieldNames {
// blank space to unify multi-columns lookup
if strings.Contains(msg+" ", strings.ToLower(tableOrAlias+"."+name)) {
// note: extra spaces to exclude table name with suffix matching the current one
// OR other fields starting with the current field name
if strings.Contains(msg, strings.ToLower(" "+tableOrAlias+"."+name+" ")) {
normalizedErrs[name] = validation.NewError("validation_not_unique", "Value must be unique")
}
}
+14
View File
@@ -78,6 +78,13 @@ func TestNormalizeUniqueIndexError(t *testing.T) {
[]string{"a", "b"},
nil,
},
{
"unique index error with table name suffix matching the specified one",
errors.New("UNIQUE constraint failed for fields test_suffix.a,test_suffix.b"),
"suffix",
[]string{"a", "b", "c"},
nil,
},
{
"unique index error but mismatched fields",
errors.New("UNIQUE constraint failed for fields test.a,test.b"),
@@ -92,6 +99,13 @@ func TestNormalizeUniqueIndexError(t *testing.T) {
[]string{"a", "b", "c"},
[]string{"a", "b"},
},
{
"unique index error with matching table name and field starting with the name of another non-unique field",
errors.New("UNIQUE constraint failed for fields test.a_2,test.c"),
"test",
[]string{"a", "a_2", "c"},
[]string{"a_2", "c"},
},
}
for _, s := range scenarios {
+11 -9
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)
@@ -437,9 +437,11 @@ func getQueryTableInfo(app App, selectQuery string) ([]*TableInfoRow, error) {
// Raw query identifiers parser
// -------------------------------------------------------------------
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
var (
joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]*?\*\/)|(--.+$)`)
)
type identifier struct {
original string
@@ -453,9 +455,9 @@ type identifiersParser struct {
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
str = commentsReplaceRegex.ReplaceAllString(str, " ")
str = joinReplaceRegex.ReplaceAllString(str, " __pb_join__ ")
str = discardReplaceRegex.ReplaceAllString(str, " __pb_discard__ ")
tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t')
@@ -490,7 +492,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
skip = false
partType = "from"
activeBuilder = &fromParts
case "_join_":
case "__pb_join__":
skip = false
// the previous part was also a join
@@ -500,7 +502,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
partType = "join"
activeBuilder = &joinParts
case "_discard_":
case "__pb_discard__":
// skip following tokens
skip = true
default:
+46 -4
View File
@@ -246,11 +246,21 @@ func TestCreateViewFields(t *testing.T) {
`
select
-- test single line
id,
text,
demo1.id,
demo1.text,
/* multi
line comment */
url, created, updated from demo1
* line comment block */
demo1.url, demo1.created, demo1.updated from/* inline comment block with no spaces between the identifiers */demo1
-- comment before join
join demo2 ON (
-- comment inside join
demo2.id = demo1.id
)
-- comment before where
where (
-- comment inside where
demo2.id = demo1.id
)
`,
false,
map[string]string{
@@ -384,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",
`
+4 -5
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() {
@@ -36,7 +36,7 @@ func main() {
&hooksWatch,
"hooksWatch",
true,
"auto restart the app on pb_hooks file change",
"auto restart the app on pb_hooks file change; it has no effect on Windows",
)
var hooksPool int
@@ -76,7 +76,7 @@ func main() {
&indexFallback,
"indexFallback",
true,
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
"fallback the request to index.html on missing static path, e.g. when pretty urls are used with SPA",
)
app.RootCmd.ParseFlags(os.Args[1:])
@@ -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"
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/core"
)

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