Compare commits

...
426 Commits
Author SHA1 Message Date
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 c1c499fc1f bumped package version 2025-01-10 08:53:42 +02:00
Gani Georgiev accc51bf65 updated Google OAuth2 endpoints 2025-01-09 15:06:45 +02:00
Gani Georgiev 0aa21c1d04 synced with master 2025-01-05 15:10:53 +02:00
Gani Georgiev cad16fac6b [#6229] fixed display fields extraction 2025-01-05 14:33:16 +02:00
Gani Georgiev b150a3a98a upgraded to jwt/v5 2025-01-05 11:18:00 +02:00
Gani Georgiev 41f1ff2b5f updated mime types autocomplete list 2025-01-04 21:53:19 +02:00
Gani Georgiev 1286b59f54 synced with master 2025-01-04 09:45:20 +02:00
Gani Georgiev 9414986ca0 updated changelog 2025-01-04 09:24:57 +02:00
Gani Georgiev 6628cdf893 updated tygoja and regenerated jsvm types 2025-01-03 22:11:25 +02:00
Gani Georgiev e521e5343a fixed JSVM types for structs and functions with multiple generic parameters 2025-01-03 21:44:07 +02:00
Gani Georgiev 412341fd78 added missing time macros in the UI autocomplete 2025-01-03 21:38:13 +02:00
Gani Georgiev ff3d51ce30 added JSVM new Timezone binding 2025-01-03 17:35:49 +02:00
Gani Georgiev dadbca5248 use the original record id in the update manage rule checks 2025-01-03 17:35:21 +02:00
Gani Georgiev 1e2a923433 added missing time macros in the UI autocomplete 2025-01-02 09:41:50 +02:00
Gani Georgiev 9e46d811d6 fixed changelog typo 2025-01-02 09:15:52 +02:00
Gani Georgiev a8b9aee24e updated otp docs 2025-01-02 08:37:37 +02:00
Gani Georgiev 36df16aaa8 reorganized changelog 2025-01-01 17:49:30 +02:00
Gani Georgiev 44a3e8478d updated go deps and jsvm types 2025-01-01 17:47:17 +02:00
Gani Georgiev 73f1b223ff directly resolve to null for auth check with missing RequestInfo.Auth field 2025-01-01 17:19:30 +02:00
Gani Georgiev c8b29edf9d bumped app version 2025-01-01 16:49:26 +02:00
Gani Georgiev a43f4bf155 reuse the random identifier 2025-01-01 16:41:47 +02:00
Gani Georgiev 3074ed3c5e fixed comment typos 2024-12-30 21:58:29 +02:00
Gani Georgiev 26cb1cef37 added ServeEvent.InstallerFunc field 2024-12-30 20:30:07 +02:00
Gani Georgiev 0155e9333f removed unnecessary request params 2024-12-29 22:29:53 +02:00
Gani Georgiev 118399cc12 fetch colletions list and scaffolds concurrently 2024-12-29 22:26:56 +02:00
Gani Georgiev b062bc6d16 updated jsvm types 2024-12-29 17:46:37 +02:00
Gani Georgiev a8952cfca2 [#6201] expanded the hidden fields check and allow targetting hidden fields in the List API rule 2024-12-29 17:31:58 +02:00
Gani Georgiev 2af9b554ad updated ui/dist 2024-12-28 10:44:51 +02:00
Gani Georgiev 2ef5459698 removed the trailing slash from the env urls 2024-12-28 10:32:14 +02:00
Gani Georgiev 074e977e90 use typed int64 const 2024-12-28 10:23:40 +02:00
Gani Georgiev 07fb052da1 added extra validators for the collection int64 field options 2024-12-28 10:13:18 +02:00
Gani Georgiev 6c53352643 [#6184] fixed unique identity fields input reactivity 2024-12-26 21:37:06 +02:00
Gani Georgiev 00372711fd updated changelog 2024-12-26 13:25:02 +02:00
Gani Georgiev d34c8ec048 added record.SetRandomPassword() helper and updated oauth2 autogenerated password handling 2024-12-26 13:24:03 +02:00
Gani Georgiev d8c0b11271 updated npm deps 2024-12-25 22:37:00 +02:00
Gani Georgiev 56f951e5a2 added crons web apis and ui listing 2024-12-25 22:24:24 +02:00
Gani Georgiev ed1917b307 [#6166] added auth collection select for the settings Send test email popup 2024-12-24 18:10:39 +02:00
Gani Georgiev 47bd4ca11e eagerly interrupt waiting for the email alert send in case it takes longer than 15s 2024-12-24 12:13:33 +02:00
Gani Georgiev 4824701b6c cache jsvm reflect created types 2024-12-24 11:07:26 +02:00
Gani Georgiev 39df26ee21 changed store.Store to accept generic key type 2024-12-23 15:44:00 +02:00
Gani Georgiev e18116d859 synced with master 2024-12-22 16:27:44 +02:00
Gani Georgiev a8dbca64b2 updated changelog 2024-12-22 16:12:36 +02:00
Gani Georgiev 3f25c71780 bumped driver version 2024-12-22 16:10:21 +02:00
Gani Georgiev bae5421d62 added Cron.Jobs() method 2024-12-22 16:05:38 +02:00
Gani Georgiev f27d9f1dc9 synced with master 2024-12-22 10:24:44 +02:00
Gani Georgiev aa52711cde bumped app version 2024-12-20 14:24:40 +02:00
Gani Georgiev 07552c2809 updated modernc versions checks 2024-12-20 14:11:09 +02:00
Gani Georgiev f6407b903b [#6152] skip the default body size limit for the backup endpoint 2024-12-20 13:45:17 +02:00
Gani Georgiev 2ebc6aecac [#6136] added warning logs in case of mismatched modernc deps 2024-12-20 12:26:01 +02:00
Gani Georgiev 884a3dec4a update js sdk 2024-12-19 22:39:22 +02:00
Gani Georgiev cb0335c2b6 normalized negative multipart/form-data numeric inferred value check 2024-12-19 17:23:03 +02:00
Gani Georgiev 274d499279 synced with master 2024-12-19 11:46:35 +02:00
Gani Georgiev a3377c992b fixed code comment typos 2024-12-19 11:41:34 +02:00
Gani Georgiev 7147633f96 bumped golang.org/x/net to 0.33.0 2024-12-19 10:09:05 +02:00
Gani Georgiev c847a6bc88 updated v0.22 changelog 2024-12-18 18:52:34 +02:00
Gani Georgiev 78a35a339f bumped app version 2024-12-18 18:27:42 +02:00
Gani Georgiev c97af83ed1 downgraded modernc,org/libc to 1.55.3 2024-12-18 18:08:03 +02:00
Gani Georgiev 803941705c [#6137] renew the superuser file token cache when clicking on the thumb preview or download link 2024-12-18 16:16:53 +02:00
Gani Georgiev ef0170cf0b [#6132] replaced strconv.Itoa with strconv.FormatInt where it could overflow 2024-12-17 21:41:55 +02:00
Gani Georgiev 76b9051011 [#5964] refresh the token key on email change 2024-12-17 11:44:27 +02:00
Gani Georgiev 0d720c3c9d print inline attachments in the dev mail send 2024-12-17 09:28:26 +02:00
Gani Georgiev 3e5e02a32c synced with master 2024-12-16 16:03:51 +02:00
Gani Georgiev 9b4200a65c updated deps and changelog 2024-12-16 15:04:19 +02:00
Gani Georgiev cb3936a499 [#6122] fixed model->record and model->collection events sync 2024-12-16 14:49:24 +02:00
Gani Georgiev e34c25858c added extra html escaping for the RECORD:* placeholders as extra measure in case the email are stored as plain html 2024-12-16 14:48:44 +02:00
Gani Georgiev fd9ae0fd1c updated js sdk 2024-12-16 10:38:20 +02:00
Gani Georgiev 7ddb0db9a4 added extra html escaping for the RECORD:* placeholders as extra measure in case the email are stored as plain html 2024-12-16 10:36:25 +02:00
Gani Georgiev 011f323bcc updated restore backup log error from debug to warn 2024-12-14 10:40:55 +02:00
Gani Georgiev d5d764f83e avoid unnecessary copying the backup archive on restore when the local file system is used 2024-12-14 10:16:36 +02:00
Gani Georgiev 239daf2023 added mailer.Message.InlineAttachments field 2024-12-13 20:37:24 +02:00
Gani Georgiev 2b2dafaf88 synced with master 2024-12-13 20:26:05 +02:00
Gani Georgiev 3098c2dcd8 [#6102] fixed JSVM exception -> Go error unwrapping 2024-12-13 17:57:14 +02:00
Gani Georgiev 8f32825cff updated jsvm types 2024-12-12 23:02:42 +02:00
Gani Georgiev 20d378cd76 updated collection db methods godoc 2024-12-12 23:01:01 +02:00
Gani Georgiev 8e63e81561 speedup records cascade delete 2024-12-12 22:55:55 +02:00
Gani Georgiev efe4ef500b removed the create api rule tooltip 2024-12-12 20:25:44 +02:00
Gani Georgiev 09a24e1de6 synced with master 2024-12-12 12:17:02 +02:00
Gani Georgiev 9490a220bc moved unnecessary reassignment 2024-12-12 12:11:47 +02:00
Gani Georgiev 4f35fb74c8 updated jsvm types 2024-12-11 19:00:45 +02:00
Gani Georgiev f533320722 updated go deps and bumped app version 2024-12-11 19:00:28 +02:00
Gani Georgiev e51456bce2 [#6073] added poc implementation for the dry submit removal 2024-12-11 18:43:48 +02:00
Gani Georgiev 35196674e6 skip unnecessary validator in case the default pattern is used 2024-12-11 18:27:21 +02:00
Gani Georgiev 7481c3f7f4 replaced LOWER with COLLATE NOCASE 2024-12-11 11:08:29 +02:00
Gani Georgiev 3ec10a9c7d fixed log message typo 2024-12-11 11:07:42 +02:00
Gani Georgiev 9efd68ff4c [#6066] updated text field validation message 2024-12-09 12:24:12 +02:00
Gani Georgiev 3634fd9c26 synced with master 2024-12-09 08:25:44 +02:00
Gani Georgiev 9747f46c1d [#6063] fixed x-forwarded-for typo 2024-12-09 04:39:14 +02:00
Gani Georgiev 88a1867169 [#6058] fixed filesystem.fileFromURL documentation and generated type 2024-12-08 17:51:30 +02:00
Gani Georgiev f7c85940c4 synced with master 2024-12-08 14:43:07 +02:00
Gani Georgiev 545a4eb47c updated ui/dist 2024-12-08 14:00:07 +02:00
Gani Georgiev 5f660d8671 updated go deps and min go release action version 2024-12-08 13:52:10 +02:00
Gani Georgiev 85c31ba068 [#6053] fixed text field max validation error message 2024-12-08 13:23:18 +02:00
Gani Georgiev 6edb344ab3 synced with upstream s3 driver 2024-12-07 12:15:03 +02:00
Gani Georgiev c91d889da3 udpated :lower modifier to apply after all other normalizations 2024-12-06 22:09:29 +02:00
Gani Georgiev 6a4e04533c added tests.NewTestAppWithConfig helper 2024-12-06 21:38:57 +02:00
Gani Georgiev e8f49c31e4 added :lower modifier 2024-12-06 16:10:11 +02:00
Gani Georgiev 1abd6ca5c0 Merge branch 'master' into develop 2024-12-06 14:21:37 +02:00
Gani Georgiev 6f55695fa1 updated jsvm types 2024-12-06 14:21:22 +02:00
Gani Georgiev 7c01441392 added yesterday and tomorrow date filter macros 2024-12-05 13:34:11 +02:00
Gani Georgiev c6695b6a75 updated min go version and removed legacy CGO note from the readme 2024-12-04 11:12:10 +02:00
Gani Georgiev b0276ad605 [#6019] fixed README example 2024-12-04 11:06:00 +02:00
Gani Georgiev 376627b4cc fixed logs search 2024-12-03 12:39:27 +02:00
Gani Georgiev 0661de0604 updated go deps 2024-12-02 14:04:11 +02:00
Gani Georgiev f80de83234 updated ui/dist 2024-12-02 14:03:16 +02:00
Gani Georgiev 2d828ef9eb added more descriptive test OTP id and failure log message 2024-12-02 13:46:29 +02:00
Gani Georgiev 77ac44a49a moved the default UI CSP as response header 2024-12-02 13:45:44 +02:00
Gani Georgiev fb2763a697 updated autodate test interceptors 2024-12-02 12:51:25 +02:00
Gani Georgiev 5835a51111 [#6000] fixed autodate interceptors 2024-12-02 12:39:02 +02:00
Gani Georgiev 3c2d43d37b updated npm deps 2024-11-29 11:59:33 +02:00
Gani Georgiev 6ee25cbe12 minimized repeated field.GetName calls 2024-11-29 11:30:54 +02:00
Gani Georgiev 67e6be8073 updated ui/dist 2024-11-29 11:19:15 +02:00
Gani Georgiev d69e81922f updated Record.Fresh and Record.Clone tests 2024-11-29 11:10:27 +02:00
Gani Georgiev 51ac522e7f [#5973] fixed Record.Fresh and Record.Clone method not properly copying the record fields 2024-11-28 23:19:21 +02:00
Gani Georgiev bcb2dca44e updated go deps 2024-11-28 16:24:21 +02:00
Gani Georgiev 79f6f4ee60 fixed comment typo 2024-11-28 15:21:19 +02:00
Gani Georgiev ab7194a639 fixed gzip middleware not applying when serving static files 2024-11-28 13:51:43 +02:00
Gani Georgiev d92016af81 updated changelog 2024-11-27 10:02:11 +02:00
Gani Georgiev 6b9d1b559c updated ui/dist 2024-11-26 22:34:23 +02:00
Gani Georgiev 44f097f7d2 updated jsvm types 2024-11-26 22:31:46 +02:00
Gani Georgiev fcf65dcc77 added collection rules change list in the confirmation popup 2024-11-26 22:28:34 +02:00
Gani Georgiev 06acaf38d8 fixed dev log query print formatting 2024-11-26 20:58:35 +02:00
Gani Georgiev cb2b27f6ed updated otp request error message 2024-11-26 19:51:21 +02:00
Gani Georgiev 6e26cb5d88 [#5958] fixed RecordQuery() custom struct scanning 2024-11-26 14:15:39 +02:00
Gani Georgiev f1b199b35c added support for passing more than one id in the Hook.Unbind method for consistency with the router 2024-11-26 11:52:26 +02:00
Gani Georgiev 0ac4a388c0 updated changelog 2024-11-25 11:39:26 +02:00
Gani Georgiev aff8abccc7 updated changelog 2024-11-25 11:36:07 +02:00
Gani Georgiev 51dfbc251a updated jsvm types 2024-11-25 11:30:59 +02:00
Gani Georgiev c933190db8 updated ui/dist 2024-11-25 11:30:01 +02:00
Gani Georgiev fab334fca6 updated error messages 2024-11-25 11:28:20 +02:00
Gani Georgiev 5a5211d7f2 [#5940] added blob response write helper 2024-11-25 09:11:58 +02:00
Gani Georgiev 8f6e91c485 added note about the trailing slash cahnge in the changelog 2024-11-24 22:10:24 +02:00
Gani Georgiev 64fa4b9cef updated changelog link 2024-11-24 21:14:54 +02:00
Gani Georgiev d5dddf3ead updated PocketBase JS SDK version 2024-11-24 16:37:01 +02:00
Gani Georgiev 62a4795a4c exclude autogenerated changelog htmls from git 2024-11-24 16:20:34 +02:00
Gani Georgiev fd18eb520f updated go deps 2024-11-24 16:14:51 +02:00
Gani Georgiev 73361370f0 added list hidden field filter test 2024-11-24 15:35:42 +02:00
Gani Georgiev ef71daae65 autofocus the installer email field 2024-11-24 15:02:48 +02:00
Gani Georgiev 5936fc3ac3 add modified fields with order in the generated migration 2024-11-24 15:02:28 +02:00
Gani Georgiev 0efca0f936 updated ui/dist and changelog 2024-11-24 12:48:03 +02:00
Gani Georgiev 1e92b51cf7 added option to insert/move fields at specific position 2024-11-24 12:41:57 +02:00
Gani Georgiev e9ece220d6 added env variables support for the thumbs generation limits 2024-11-23 20:38:30 +02:00
Gani Georgiev 2dd4e38e1d fixed superuser OTP id input reactivity, enabled truncate dropdown option for system collections, updated jsvm types 2024-11-23 14:12:18 +02:00
Gani Georgiev b34453afc7 show the OTP id in the superuser form 2024-11-23 12:44:03 +02:00
Gani Georgiev c3b347af4b added note and tests regarding the shared batch Authorization header 2024-11-23 11:54:25 +02:00
Gani Georgiev 68e9c55925 updated checkOldPassword validator 2024-11-23 10:28:31 +02:00
Gani Georgiev e5800875c2 updated ui/dist and added fallback debug log 2024-11-22 23:30:44 +02:00
Gani Georgiev 2e43518bb4 synced ported cors middleware 2024-11-22 23:19:23 +02:00
Gani Georgiev e5f1bc3c37 renamed Monday.FetchRawUserData to Monday.FetchUserInfo 2024-11-22 22:43:16 +02:00
Gani Georgievandchnfyi e3bf81cb79 [#5909] added Linear OAuth2 provider
Co-authored-by: chnfyi <143424481+chnfyi@users.noreply.github.com>
2024-11-22 22:42:04 +02:00
Gani Georgiev 305e183f58 added apis.ToApiError alias 2024-11-22 20:44:29 +02:00
Gani Georgiev 7795a086ef hide batch api preview for view collections 2024-11-22 16:18:52 +02:00
Gani Georgiev 5d8a8dd7d8 updated godoc and renamed cors middleware handler 2024-11-21 22:22:58 +02:00
Gani Georgiev 31d3c27f43 add s390x and ppc64le linux builds 2024-11-21 16:55:27 +02:00
Gani Georgiev c3ac28a8e6 updated go.mod 2024-11-21 16:33:40 +02:00
Gani Georgiev ab26941d3f enabled Buffer goja_nodejs module 2024-11-21 16:29:42 +02:00
Gani Georgiev a18df14f4f updated js sdk and other npm deps 2024-11-21 12:24:04 +02:00
Gani Georgiev c2e7ab8d41 fixed oauth2 redirect test 2024-11-21 12:11:00 +02:00
Gani Georgiev 7ee6b11e9d return an error in case of required MFA so that external handlers can react if necessary 2024-11-21 11:12:25 +02:00
Gani Georgiev 8ab02ce402 updared rate limiter sort rules 2024-11-21 11:11:51 +02:00
Gani Georgiev 779f6c1a74 added record json copy option 2024-11-20 21:04:42 +02:00
Gani Georgiev 34d7ac0808 close channel on client discard 2024-11-19 23:23:25 +02:00
Gani Georgiev d0795bd849 updated tests 2024-11-19 22:37:44 +02:00
Gani Georgiev 08f2190ad1 [#5898] instead of unregister, unset the realtime client auth state on delete of the related auth record 2024-11-19 22:36:32 +02:00
Gani Georgiev d919d55b5e allow mixing existing file names and new uploaded files 2024-11-19 17:45:15 +02:00
Gani Georgiev 52c64318c5 fixed comment typo 2024-11-19 17:27:18 +02:00
Gani Georgiev 9fe4f87e5b added required validator for the TextField.Pattern option in case it is a primary key 2024-11-19 17:21:43 +02:00
Gani Georgiev 48328bf33f added extra validations in case of id pattern validator misuse 2024-11-19 15:59:59 +02:00
Gani Georgiev 52e85a8036 added oauth2 db errors handling and replaced the auth response map with a struct 2024-11-18 21:16:20 +02:00
Gani Georgiev 37538f2a6d auto sort rate limit rules 2024-11-18 16:07:52 +02:00
Gani Georgiev 70df03ffbb fixed rate limiter rules matching to acount for the Audience field 2024-11-18 14:46:06 +02:00
Gani Georgiev 487e83c84e fixed typo and cleanup destroyed overlay wrappers 2024-11-18 08:23:39 +02:00
Gani Georgiev 14aaeb06fc fixed responsive styles of the OAuth2 providers list 2024-11-18 07:51:19 +02:00
Gani Georgiev 19ea9cca36 removed old system created and updated fields from the record preview 2024-11-18 05:56:29 +02:00
Gani Georgiev 6090b29070 [#5887] fixed record duplicate and removed the duplicated id field in the record preview 2024-11-18 05:53:20 +02:00
Gani Georgiev 957ba6266d updated changelogs 2024-11-17 15:54:23 +02:00
Gani Georgiev e1472d922a updated ui/dist 2024-11-17 15:38:38 +02:00
Gani Georgiev 3653e70a6d added periodic wal_checkpoint calls 2024-11-17 14:26:42 +02:00
Gani Georgiev 7f2fcc0046 added manual WAL checkpoints before creating the zip backup to avoid copying unnecessary data 2024-11-17 12:33:23 +02:00
Gani Georgiev 32e0eadc2f updated godoc links 2024-11-16 18:57:00 +02:00
Gani Georgiev 18a7549e50 updated collection indexes on system fields validator and normalized v0.23 old collections migration 2024-11-15 09:13:00 +02:00
Gani Georgiev e53c30ca4d added 'table is locked' error msg retry check 2024-11-15 07:45:27 +02:00
Gani Georgiev 4adc4b28d2 updated changelog 2024-11-14 17:19:27 +02:00
Gani Georgiev c5554e22e0 refresh the old collections in the UI import on successful import submission 2024-11-14 16:48:41 +02:00
Gani Georgiev 846136dcfb restored mfa ErrNoRows check 2024-11-13 20:24:52 +02:00
Gani Georgiev bc378d33f6 updated go deps 2024-11-13 20:15:15 +02:00
Gani Georgiev cc833ad643 updated mfa defaults and errors check 2024-11-13 20:14:27 +02:00
Gani Georgiev 396aa0f97c always load the full record on record view 2024-11-13 19:55:33 +02:00
Gani Georgiev 9f606bdeca otp changes - added sentTo field, allow e.Record to be nil when requesting OTP, etc. 2024-11-13 18:34:43 +02:00
Gani Georgiev 10a5c685ab removed the dynamic dashboard path option as it could complicate unnecessary too many things (oauth2 redirects, default email templates, etc.) 2024-11-12 12:32:26 +02:00
Gani Georgiev 1ca90e5e8b fixed typo 2024-11-11 16:24:46 +02:00
Gani Georgiev db57572a54 lowered the max field id and name length limit to 100 2024-11-11 16:18:24 +02:00
Gani Georgiev 0af8f3cc66 updated go deps 2024-11-11 16:04:40 +02:00
Gani Georgievandtigawanna 438ecd88a0 [#5829] added WakaTime OAuth2 provider
Co-authored-by: tigawanna <denniskinuthiawaweru@gmail.com>
2024-11-11 16:03:46 +02:00
Gani Georgiev 45628a919f added search filter and sort limits 2024-11-11 14:58:43 +02:00
Gani Georgiev fc133d8665 fixed settings test and error typo 2024-11-11 14:25:21 +02:00
Gani Georgiev 5e6d4d2126 added rate limit helpers for future use 2024-11-11 14:24:54 +02:00
Gani Georgiev c38e7c36a6 added throttling on too many failed search attempts 2024-11-11 14:24:38 +02:00
Gani Georgiev a89960db71 aligned struct fields 2024-11-11 00:04:22 +02:00
Gani Georgiev 7119ff4716 renamed local field var 2024-11-11 00:03:25 +02:00
Gani Georgiev 339399b0a4 updated otp manual rate limiter 2024-11-09 12:24:46 +02:00
Gani Georgiev 9cb6adab4d added superuser otp command 2024-11-08 18:04:22 +02:00
Gani Georgiev f6aef4471d added RateLimitRule.Audience field 2024-11-08 18:04:13 +02:00
Gani Georgiev 0e56521e8a fixed rc10 migration 2024-11-08 09:43:57 +02:00
Gani Georgiev 4f8f320280 updated changelog 2024-11-07 14:15:56 +02:00
Gani GeorgievandPedro Costa 0daa584461 [#5588] replaced deprecated Instagram Basic Display provider with a new Instagram Login
Co-authored-by: Pedro Costa <550684+pnmcosta@users.noreply.github.com>
2024-11-07 13:51:21 +02:00
Gani Georgiev 57f615a58c added default users collection rules 2024-11-07 13:24:08 +02:00
Gani Georgiev 1bd0baf328 updated v0.22.x changelog 2024-11-07 13:00:29 +02:00
Gani Georgiev 6694215909 updated changelog 2024-11-06 20:25:34 +02:00
Gani Georgiev 241a81e1fc updated installer note 2024-11-06 20:06:34 +02:00
Gani Georgiev bed45beb13 updated comments and pass the dashboard path into the installer 2024-11-06 19:19:16 +02:00
Gani Georgiev e4cd6810ab always register the installer hooks in case the superuser is created by a console command 2024-11-06 14:23:16 +02:00
Gani Georgiev f38700982c removed RequestEvent.UnsafeRealIP 2024-11-05 21:49:45 +02:00
Gani Georgiev 9506669095 refactored installer and removed RequireSuperuserAuthOnlyIfAny 2024-11-05 21:12:17 +02:00
Gani Georgiev 4f67dba6cb [#5800] skip default loadAuthToken middleware if e.Auth is already set 2024-11-05 09:08:52 +02:00
Gani Georgiev 47354f5aa9 updated jsvm types 2024-11-05 00:16:20 +02:00
Gani Georgiev 2f35ef29fe [#5797] fixed JSVM types errors 2024-11-05 00:06:48 +02:00
Gani Georgiev 755149c915 [#5793] added subscriptions.Broker.TotalClients() method 2024-11-04 19:19:06 +02:00
Gani Georgiev f9a2d6c6ae updated ui/dist 2024-11-04 19:08:21 +02:00
Gani Georgiev 9e70c77736 added migration to normalize the system collection and field ids 2024-11-04 19:03:33 +02:00
Gani Georgiev b3d88349d7 updated superusers test tokens with the new id 2024-11-04 15:48:28 +02:00
Gani Georgiev 8d0e4a0460 restore crc32 checksum for the colelction and field ids 2024-11-04 10:51:32 +02:00
Gani Georgiev 83d91b3dd5 added realtime topic length validator 2024-11-03 13:14:39 +02:00
Gani Georgiev 8c71a291ff silence hooks watch errors and just print as warning 2024-11-03 12:52:52 +02:00
Gani Georgiev 106ce0f0c4 added support for specifying collection id with the factory and added collections indexes validator to prevent duplicated definitions 2024-11-03 10:44:48 +02:00
Gani Georgiev c3557d4e94 [#5789] updated the hooks watcher to account for the case when hooksDir is a symlink 2024-11-03 10:42:58 +02:00
Gani Georgiev 1025fb2406 show generic index error and added support to autoreplace the collection id if part of an index name 2024-11-02 22:34:13 +02:00
Gani Georgiev fadb4e3d67 removed JSVM Collection class aliases 2024-11-02 22:00:58 +02:00
Gani Georgiev a446290a7a normalized Collection struct methods receiver 2024-11-02 11:20:21 +02:00
Gani Georgiev bc83fddaf2 updated changelog 2024-11-02 09:34:24 +02:00
Gani Georgiev 00da008d64 updated Store.GetOrSet to lock first with RLock/RUnlock 2024-11-01 22:06:53 +02:00
Gani Georgiev d3ca24e509 added more user friendly error message in case ServeEvent.Next() is not invoked 2024-11-01 19:10:33 +02:00
Gani Georgiev 1a1e3a2741 renamed build tag 2024-11-01 19:10:08 +02:00
Gani Georgiev b11ea60436 added rate limit label format info 2024-11-01 14:05:42 +02:00
Gani Georgiev 0c054bc6ff updated impersonate popup styles 2024-10-31 12:59:15 +02:00
Gani Georgiev 697545e73f auto select the first non-system collection on collection remove 2024-10-30 09:08:31 +02:00
Gani Georgiev 39415e0f49 don't unnecessary loads the records list while refrishing the collections 2024-10-30 08:44:09 +02:00
Gani Georgiev 8b42941bd3 removed the default cgo driver registration 2024-10-29 22:46:56 +02:00
Gani Georgiev 5a94ec9918 [#5741] use random string as id for non-system collections and fields 2024-10-29 20:08:16 +02:00
Gani Georgiev 658f0c4177 added support to reference collections in the UI with both their name and id 2024-10-29 13:38:30 +02:00
Gani Georgiev 421310d2f6 updated API preview disabled checks 2024-10-27 22:53:39 +02:00
Gani Georgiev 005047099d updated API preview 2024-10-27 22:12:37 +02:00
Gani Georgiev 49db093a51 fixed auto www redirect due to missing schema 2024-10-27 21:01:44 +02:00
Gani Georgiev 8646960abc reduce the default prewarmed jsvm pool size to 15 2024-10-25 12:27:10 +03:00
Gani Georgiev 9d2637847d fixed typo 2024-10-25 10:36:40 +03:00
Gani Georgiev 747c490b0a fixed test case typo 2024-10-25 10:17:01 +03:00
Gani Georgiev cb46591e70 updated changelog 2024-10-24 22:06:29 +03:00
Gani Georgiev 4c0b2154b0 updated ui/dist 2024-10-24 22:03:54 +03:00
Gani Georgiev 8c45d4d92d lock the _mfas and _otps delete api rule, fixed flaky tests, fixed jsvm types example 2024-10-24 21:59:00 +03:00
Gani Georgiev 0b7741f1f7 added additional godoc and updated the OAuth2 form to use the same created record pointer 2024-10-24 08:37:22 +03:00
Gani Georgiev c41a4dfc07 added v0.22.22 changelog entry 2024-10-18 17:43:33 +03:00
Gani Georgiev f3c948aa13 updated go deps 2024-10-18 17:42:48 +03:00
Gani Georgiev be908ad4bf added NaN checks 2024-10-18 17:38:19 +03:00
Gani Georgiev ae86525c13 updated typo 2024-10-18 14:18:32 +03:00
445 changed files with 29205 additions and 13254 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '>=1.23.0'
go-version: '>=1.23.9'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
+2
View File
@@ -15,4 +15,6 @@ coverage.out
# generated markdown previews
README.html
CHANGELOG.html
CHANGELOG_16_22.html
CHANGELOG_8_15.html
LICENSE.html
+10 -13
View File
@@ -9,19 +9,6 @@ before:
- go mod tidy
builds:
# used only for tests
- id: build_cgo
main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
- id: build_noncgo
main: ./examples/base
binary: pocketbase
@@ -37,13 +24,23 @@ builds:
- amd64
- arm64
- arm
- s390x
- ppc64le
goarm:
- 7
ignore:
- goos: windows
goarch: arm
- goos: windows
goarch: s390x
- goos: windows
goarch: ppc64le
- goos: darwin
goarch: arm
- goos: darwin
goarch: s390x
- goos: darwin
goarch: ppc64le
release:
draft: true
+422 -122
View File
@@ -1,175 +1,469 @@
## v0.23.0-rc7
## v0.28.1
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Attach the default panic-recover middleware after the activity logger so that we can log the error.
- Updated the `RequestEvent.BindBody` FormData type inferring rules to convert numeric strings into float64 only if the resulting minimal number string representation matches the initial FormData string value ([#5687](https://github.com/pocketbase/pocketbase/issues/5687)).
- Fixed the JSVM types to include properly generated function declarations when the related Go functions have shortened/combined return values.
- Reorganized the record table fields<->columns syncing to remove the `PRAGMA writable_schema` usage.
- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)).
## v0.23.0-rc6
## v0.28.0
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- 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)).
- Fixed realtime 403 API error on resubscribe ([#5674](https://github.com/pocketbase/pocketbase/issues/5674)).
- 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()`._
- Fixed the auto OAuth2 avatar mapped field assignment when the OAuth2 provider doesn't return an avatar URL ([#5673](https://github.com/pocketbase/pocketbase/pull/5673)).
_In case the avatar retrieval fails and the mapped record field "Required" option is not set, the error is silenced and only logged with WARN level._
- ⚠️ 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._
- Added `Router.SEARCH(path, action)` helper method for registering `SEARCH` endpoints.
- ⚠️ 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`._
- Changed all builtin middlewares to return `*hook.Handler[*core.RequestEvent]` with a default middleware id for consistency and to allow removal.
Or in other words, replace `.BindFunc(apis.Gzip())` with `.Bind(apis.Gzip())`.
- 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)._
- Updated the JSVM types to reflect the recent changes.
- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.)
## v0.23.0-rc5
## v0.27.2
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)).
- Added Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)).
- Added monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
- Added option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
- Fixed the relation record picker to sort by default by `@rowid` instead of the `created` field as the latter is optional ([#5641](https://github.com/pocketbase/pocketbase/discussions/5641)).
- Fixed the UI "Set Superusers only" button click not properly resetting the input state.
- Fixed the OAuth2 providers logo path shown in the "Authorized providers" UI.
- Fixed the single value UI for the `select`, `file` and `relation` fields ([#5646](https://github.com/pocketbase/pocketbase/discussions/5646))
- 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.23.0-rc4
## v0.27.1
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Updated example `geoPoint` API preview body data.
- Fixed the UI settings update form to prevent sending empty string for the mail password or the S3 secret options on resave of the form.
- Added JSVM `new GeoPointField({ ... })` constructor.
- ⚠️ Added an exception for the `OAuth2` field in the GO->JSVM name mapping rules:
```
// old -> new
collection.oAuth2.* -> collection.oauth2.*
```
- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)).
- Added more user friendly view collection truncate error message.
- Updated npm dev dependencies.
- Added an extra suffix character to the name of autogenerated template migration file for `*test` suffixed collections to prevent acidentally resulting in `_test.go` migration files.
- Added `FieldsList.AddMarshaledJSON([]byte)` helper method to load a serialized json array of objects or a single json object into an existing collection fields list.
- Fixed the autogenerated Go migration template when updating a collection ([#5631](https://github.com/pocketbase/pocketbase/discussions/5631)).
⚠️ If you have already used a previous prerelease and have autogenerated Go migration files, please check the migration files named **`{timestamp}_updated_{collection}.go`** and manually change:
<table width="100%">
<tr>
<th width="50%">Old (broken)</th>
<th width="50%">New</th>
</tr>
<tr>
<td width="50%">
## 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
// add field / update field
if err := json.Unmarshal([]byte(`[{
tests.ApiScenario{
Name: "Example test that checks a Create API rule failure"
Method: http.MethodPost,
URL: "/api/collections/example/records",
...
}]`), &collection.Fields); err != nil {
return err
// 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.
</td>
<td width="50%">
- 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.
```go
// add field / update field
if err := collection.Fields.AddMarshaledJSON([]byte(`{
...
}`)); err != nil {
return err
}
- 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
```
</td>
</tr>
</table>
To test that your Go migration files work correctly you can try to start PocketBase with a new temp pb_data, e.g.:
- 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.
```go
go run . serve --dir="pb_data_temp"
- 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.23.0-rc3
## v0.25.3
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Make `PRAGMA optimize` statement optional in case it is not supported by the driver ([#5611](https://github.com/pocketbase/pocketbase/discussions/5611)).
- Reapply the minimum required `pb_data/auxiliary.db` migrations if the db file was manually deleted ([#5618](https://github.com/pocketbase/pocketbase/discussions/5618)).
- To avoid confusion and unnecessary casting, the `hook.HandlerFunc[T]` type has been removed and instead everywhere we now use directly the underlying function definition, aka.:
```go
func(T) error
```
- Fixed the UI input field type of the OTP.length field ([#5617](https://github.com/pocketbase/pocketbase/issues/5617)).
- Other minor fixes (fixed API preview and examples error message typos, better hint for combined/multi-spaced view query columns, fixed the path for the HTTPS green favicon path, etc.).
- 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.23.0-rc2
## v0.25.2
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
- Small update to the earlier v0.23.0-rc that uses `pb_data/auxiliary.db` instead of `pb_data/aux.db` because it seems that on Windows `aux` is disallowed as file name ([#5607](https://github.com/pocketbase/pocketbase/issues/5607)).
_If you have already upgraded to v0.23.0-rc please rename manually your `pb_data/aux.db` file to `pb_data/auxiliary.db`._
- 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.23.0-rc
## v0.25.1
> [!CAUTION]
> **This is a prerelease intended for test and experimental purposes only!**
>
> It introduces many Go/JSVM breaking changes and requires manual migration steps.
>
> All new features will be reflected in the new website documentation with the final v0.23.0 release.
- Fixed the batch API Preview success sample response.
- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
## v0.25.0
- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
_For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
- `/v3/userinfo` auth response changes:
```
meta.rawUser.id => meta.rawUser.sub
meta.rawUser.verified_email => meta.rawUser.email_verified
```
- `/v2/auth` query parameters changes:
If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
_You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
- Upgraded to `golang-jwt/jwt/v5`.
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
## v0.24.4
- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
## v0.24.3
- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
- Reload the created/edited records data in the RecordsPicker UI.
- Updated Go dependencies.
## v0.24.2
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
## v0.24.1
- Added missing time macros in the UI autocomplete.
- Fixed JSVM types for structs and functions with multiple generic parameters.
## v0.24.0
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records
(_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_).
As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)).
- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types.
For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`.
- Added `@yesterday` and `@tomorrow` datetime filter macros.
- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
_The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
## v0.23.12
- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)).
- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
## v0.23.11
- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
## v0.23.10
- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
## v0.23.9
- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)).
## v0.23.8
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.).
## v0.23.7
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
## v0.23.6
- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)).
- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)).
- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
## v0.23.5
- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used.
- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)).
- Other minor fixes (comment typos, JSVM types update).
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
## v0.23.4
- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
- Updated Go and npm dependencies.
## v0.23.3
- Fixed Gzip middleware not applying when serving static files.
- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)).
## v0.23.2
- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)).
- Fixed `--dev` log query print formatting.
- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
- Added collection rules change list in the confirmation popup
(_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_).
## v0.23.1
- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)).
- Added more descriptive error messages.
## v0.23.0
> [!NOTE]
> Please note that you don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
> You don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
> your existing app and/or are satisfied with the v0.22.x features set. There are no identified critical issues
> with PocketBase v0.22.x yet and in the case of critical bugs and security vulnerabilities, the fixes
> will be backported for at least until Q1 of 2025 (_if not longer_).
>
> If you don't plan upgrading just make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:
> **If you don't plan upgrading make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:**
> - JS SDK: `<0.22.0`
> - Dart SDK: `<0.19.0`
PocketBase v0.23.0-rc is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
> [!CAUTION]
> This release introduces many Go/JSVM and Web APIs breaking changes!
>
> Existing `pb_data` will be automatically upgraded with the start of the new executable,
> but custom Go or JSVM (`pb_hooks`, `pb_migrations`) and JS/Dart SDK code will have to be migrated manually.
> Please refer to the below upgrade guides:
> - Go: https://pocketbase.io/v023upgrade/go/.
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
>
> If you had already switched to some of the earlier `<v0.23.0-rc14` versions and have generated a full collections snapshot migration (aka. `./pocketbase migrate collections`), then you may have to regenerate the migration file to ensure that it includes the latest changes.
There are many changes but to highlight some of the most notable ones:
PocketBase v0.23.0 is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
There are a lot of changes but to highlight some of the most notable ones:
- New and more [detailed documentation](https://pocketbase.io/docs/).
_The old documentation could be accessed at [pocketbase.io/old](https://pocketbase.io/old/)._
- Replaced `echo` with a new router built on top of the Go 1.22 `net/http` mux enhancements.
- Merged `daos` packages in `core.App` to simplify the DB operations (_the `models` package structs are also migrated in `core`_).
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
- Option to mark field as Private/Hidden, disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
- Admins are now system `_superusers` auth records.
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
@@ -179,23 +473,25 @@ There are many changes but to highlight some of the most notable ones:
- One-Time Password (OTP) auth method (_via email code_).
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
- Support for Record "proxy/projection" in preparation for the planned autogeneration of typed Go record models.
- Linear OAuth2 provider ([#5909](https://github.com/pocketbase/pocketbase/pull/5909); thanks @chnfyi).
- WakaTime OAuth2 provider ([#5829](https://github.com/pocketbase/pocketbase/pull/5829); thanks @tigawanna).
- Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
- monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
- New Instagram provider compatible with the new Instagram Login APIs ([#5588](https://github.com/pocketbase/pocketbase/pull/5588); thanks @pnmcosta).
_The provider key is `instagram2` to prevent conflicts with existing linked users._
- Option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
- Various minor UI improvements (_recursive `Presentable` view, slightly different collection options organization, zoom/pan for the logs chart, etc._)
- and many more...
In terms of performance, the Go standard router mux is known to be slightly slower compared to Gin, Echo, etc. implementations, but based on my local tests the difference is negliable.
The [benchmarks repo](https://github.com/pocketbase/benchmarks) will be updated with the final v0.23.0 release (_currently there seems to be ~10% memory consumption increase which I'll have to investigate to see whether it is from the router change or from the new hooks_).
#### Go/JSVM APIs changes
For upgrading to PocketBase v0.23.0, please refer to:
- Go: https://pocketbase.io/v023upgrade/go/.
- JSVM: https://pocketbase.io/v023upgrade/jsvm/.
> - Go: https://pocketbase.io/v023upgrade/go/.
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
#### SDKs changes
- [JS SDK v0.22.0-rc](https://github.com/pocketbase/js-sdk/blob/develop/CHANGELOG.md)
- [Dart SDK v0.19.0-rc](https://github.com/pocketbase/dart-sdk/blob/develop/CHANGELOG.md)
- [JS SDK v0.22.0](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
- [Dart SDK v0.19.0](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
#### Web APIs changes
@@ -211,6 +507,10 @@ For upgrading to PocketBase v0.23.0, please refer to:
- New `POST /api/collections/{collection}/impersonate/{id}` endpoint.
- ⚠️ If you are constructing requests to `/api/*` routes manually remove the trailing slash (_there is no longer trailing slash removal middleware registered by default_).
- ⚠️ Removed `/api/admins/*` endpoints because admins are converted to `_superusers` auth collection records.
- ⚠️ Previously when uploading new files to a multiple `file` field, new files were automatically appended to the existing field values.
This behaviour has changed with v0.23+ and for consistency with the other multi-valued fields when uploading new files they will replace the old ones. If you want to prepend or append new files to an existing multiple `file` field value you can use the `+` prefix or suffix:
```js
+92 -1
View File
@@ -2,6 +2,97 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.34
- (_Backported from v0.26.6_) Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
## v0.22.33
- (_Backported from v0.26.3_) Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
## v0.22.32
- (_Backported from v0.26.2_) Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
## v0.22.31
- (_Backported from v0.25.5_) Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
## v0.22.30
- (_Backported from v0.24.4_) Fixed fields extraction for view queries with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
## v0.22.29
- (_Backported from v0.23.11_) Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
## v0.22.28
- (_Backported from v0.23.10_) Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
- (_Backported from v0.23.10_) Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
## v0.22.27
- Instead of unregistering the realtime clients, we now just unset their auth state on delete of the related auth record so that the clients can receive the `delete` event ([#5898](https://github.com/pocketbase/pocketbase/issues/5898)).
## v0.22.26
- (_Backported from v0.23.0-rc_) Added manual WAL checkpoints before creating the zip backup to minimize copying unnecessary data.
## v0.22.25
- Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)).
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibity of eventual side-channel attacks.
## v0.22.24
- Delete new uploaded record files in case of DB persist error ([#5845](https://github.com/pocketbase/pocketbase/issues/5845)).
## v0.22.23
- Updated the hooks watcher to account for the case when hooksDir is a symlink ([#5789](https://github.com/pocketbase/pocketbase/issues/5789)).
- _(Backported from v0.23.0-rc)_ Registered a default `http.Server.ErrorLog` handler to report general server connection errors as app Debug level logs (e.g. invalid TLS handshakes caused by bots trying to access your server via its IP or other similar errors).
- Other minor fixes (updated npm dev deps to fix the vulnerabilities warning, added more user friendly realtime topic length error, regenerated JSVM types, etc.)
## v0.22.22
- Added deprecation log in case Instagram OAuth2 is used (_related to [#5652](https://github.com/pocketbase/pocketbase/discussions/5652)_).
- Added `update` command warning to prevent unnecessary downloading PocketBase v0.23.0 since it will contain breaking changes.
- Added global JSVM `toString()` helper (_successor of `readerToString()`_) to stringify any value (bool, number, multi-byte array, io.Reader, etc.).
_`readerToString` is still available but it is marked as deprecated. You can also use `toString` as replacement for of `String.fromCharCode` to properly stringify multi-byte unicode characters like emojis._
```js
decodeURIComponent(escape(String.fromCharCode(...bytes))) -> toString(bytes)
```
- Updated `aws-sdk-go-v2` and removed deprecated `WithEndpointResolverWithOptions`.
- Backported some of the v0.23.0-rc form validators, fixes and tests.
- Bumped GitHub action min Go version and dependencies.
## v0.22.21
- Lock the logs database during backup to prevent `database disk image is malformed` errors in case there is a log write running in the background ([#5541](https://github.com/pocketbase/pocketbase/discussions/5541)).
@@ -1093,7 +1184,7 @@
- Updated the schema fields Admin UI for "tidier" fields visualization.
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forwarded-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
+8 -12
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
@@ -54,7 +57,6 @@ Here is a minimal example:
import (
"log"
"net/http"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
@@ -65,7 +67,7 @@ Here is a minimal example:
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// registers new "GET /hello" route
se.Router.Get("/hello", func(re *core.RequestEvent) error {
se.Router.GET("/hello", func(re *core.RequestEvent) error {
return re.String(200, "Hello world!")
})
@@ -84,19 +86,13 @@ Here is a minimal example:
4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
> [!NOTE]
> PocketBase embeds SQLite, but doesn't require CGO.
>
> If CGO is enabled (aka. `CGO_ENABLED=1`), it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
> Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
_For more details please refer to [Extend with Go](https://pocketbase.io/docs/go-overview/)._
### Building and running the repo main.go example
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
0. [Install Go 1.21+](https://go.dev/doc/install) (_if you haven't already_)
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo
2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
+5
View File
@@ -6,6 +6,11 @@ import "github.com/pocketbase/pocketbase/tools/router"
// and for consistency with the JSVM binds.
// -------------------------------------------------------------------
// ToApiError wraps err into ApiError instance (if not already).
func ToApiError(err error) *router.ApiError {
return router.ToApiError(err)
}
// NewApiError is an alias for [router.NewApiError].
func NewApiError(status int, message string, errData any) *router.ApiError {
return router.NewApiError(status, message, errData)
+2 -2
View File
@@ -18,10 +18,10 @@ func bindBackupApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
sub := rg.Group("/backups")
sub.GET("", backupsList).Bind(RequireSuperuserAuth())
sub.POST("", backupCreate).Bind(RequireSuperuserAuth())
sub.POST("/upload", backupUpload).Bind(RequireSuperuserAuthOnlyIfAny())
sub.POST("/upload", backupUpload).Bind(BodyLimit(0), RequireSuperuserAuth())
sub.GET("/{key}", backupDownload) // relies on superuser file token
sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuthOnlyIfAny())
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
}
type backupFileInfo struct {
+31 -60
View File
@@ -10,9 +10,10 @@ import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"gocloud.dev/blob"
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
)
func TestBackupsList(t *testing.T) {
@@ -53,7 +54,7 @@ func TestBackupsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/backups",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{`[]`},
@@ -64,7 +65,7 @@ func TestBackupsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/backups",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -120,7 +121,7 @@ func TestBackupsCreate(t *testing.T) {
Method: http.MethodPost,
URL: "/api/backups",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Store().Set(core.StoreKeyActiveBackup, "")
@@ -137,7 +138,7 @@ func TestBackupsCreate(t *testing.T) {
Method: http.MethodPost,
URL: "/api/backups",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
@@ -166,7 +167,7 @@ func TestBackupsCreate(t *testing.T) {
URL: "/api/backups",
Body: strings.NewReader(`{"name":"!test.zip"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
@@ -184,7 +185,7 @@ func TestBackupsCreate(t *testing.T) {
URL: "/api/backups",
Body: strings.NewReader(`{"name":"test.zip"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
@@ -289,7 +290,7 @@ func TestBackupUpload(t *testing.T) {
Method: http.MethodPost,
URL: "/api/backups/upload",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
@@ -305,7 +306,7 @@ func TestBackupUpload(t *testing.T) {
Body: bodies[3].buffer,
Headers: map[string]string{
"Content-Type": bodies[3].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
fsys, err := app.NewBackupsFilesystem()
@@ -335,7 +336,7 @@ func TestBackupUpload(t *testing.T) {
Body: bodies[4].buffer,
Headers: map[string]string{
"Content-Type": bodies[4].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
@@ -347,28 +348,17 @@ func TestBackupUpload(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "unauthorized with 0 superusers (valid file)",
Name: "ensure that the default body limit is skipped",
Method: http.MethodPost,
URL: "/api/backups/upload",
Body: bodies[5].buffer,
Body: bytes.NewBuffer(make([]byte, apis.DefaultMaxBodySize+100)),
Headers: map[string]string{
"Content-Type": bodies[5].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
}
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"*": 0},
ExpectedStatus: 400, // it doesn't matter as long as it is not 413
ExpectedContent: []string{`"data":{`},
NotExpectedContent: []string{"entity too large"},
ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -415,7 +405,7 @@ func TestBackupsDownload(t *testing.T) {
Method: http.MethodGet,
URL: "/api/backups/test1.zip",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -468,7 +458,7 @@ func TestBackupsDownload(t *testing.T) {
{
Name: "with valid superuser auth token",
Method: http.MethodGet,
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
@@ -481,7 +471,7 @@ func TestBackupsDownload(t *testing.T) {
{
Name: "with expired superuser file token",
Method: http.MethodGet,
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.hTNDzikwJdcoWrLnRnp7xbaifZ2vuYZ0oOYRHtJfnk4",
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.nqqtqpPhxU0045F4XP_ruAkzAidYBc5oPy9ErN3XBq0",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
@@ -494,20 +484,20 @@ func TestBackupsDownload(t *testing.T) {
{
Name: "with valid superuser file token but missing backup name",
Method: http.MethodGet,
URL: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
URL: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with valid superuser file token",
Method: http.MethodGet,
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
@@ -524,7 +514,7 @@ func TestBackupsDownload(t *testing.T) {
{
Name: "with valid superuser file token and backup name with escaped char",
Method: http.MethodGet,
URL: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
URL: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
@@ -601,7 +591,7 @@ func TestBackupsDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/backups/missing.zip",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -620,7 +610,7 @@ func TestBackupsDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/backups/test1.zip",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -642,7 +632,7 @@ func TestBackupsDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/backups/test1.zip",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -678,7 +668,7 @@ func TestBackupsDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/backups/%40test4.zip",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -751,7 +741,7 @@ func TestBackupsRestore(t *testing.T) {
Method: http.MethodPost,
URL: "/api/backups/missing.zip/restore",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -767,7 +757,7 @@ func TestBackupsRestore(t *testing.T) {
Method: http.MethodPost,
URL: "/api/backups/test1.zip/restore",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
@@ -780,25 +770,6 @@ func TestBackupsRestore(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "unauthorized with no superusers (checks only access)",
Method: http.MethodPost,
URL: "/api/backups/missing.zip/restore",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
+1
View File
@@ -41,6 +41,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindRecordAuthApi(app, apiGroup)
bindLogsApi(app, apiGroup)
bindBackupApi(app, apiGroup)
bindCronApi(app, apiGroup)
bindFileApi(app, apiGroup)
bindBatchApi(app, apiGroup)
bindRealtimeApi(app, apiGroup)
+19 -13
View File
@@ -29,14 +29,14 @@ func bindBatchApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
type HandleFunc func(e *core.RequestEvent) error
type BatchActionHandlerFunc func(app core.App, ir *core.InternalRequest, params map[string]string, next func() error) HandleFunc
type BatchActionHandlerFunc func(app core.App, ir *core.InternalRequest, params map[string]string, next func(data any) error) HandleFunc
// ValidBatchActions defines a map with the supported batch InternalRequest actions.
//
// Note: when adding new routes make sure that their middlewares are inlined!
var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
// "upsert" handler
regexp.MustCompile(`^PUT /api/collections/(?P<collection>[^\/\?]+)/records(?P<query>\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func() error) HandleFunc {
regexp.MustCompile(`^PUT /api/collections/(?P<collection>[^\/\?]+)/records(?P<query>\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
var id string
if len(ir.Body) > 0 && ir.Body["id"] != "" {
id = cast.ToString(ir.Body["id"])
@@ -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() error) HandleFunc {
return recordCreate(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(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() error) HandleFunc {
return recordUpdate(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(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() error) HandleFunc {
return recordDelete(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(false, next)
},
}
@@ -246,7 +246,7 @@ func (p *batchProcessor) process(activeApp core.App, batch []*core.InternalReque
p.baseEvent,
batch[0],
p.infoContext,
func() error {
func(_ any) error {
if len(batch) == 1 {
return nil
}
@@ -277,7 +277,7 @@ func processInternalRequest(
baseEvent *core.RequestEvent,
ir *core.InternalRequest,
infoContext string,
optNext func() error,
optNext func(data any) error,
) (*BatchRequestResult, error) {
handle, params, ok := prepareInternalAction(activeApp, ir, optNext)
if !ok {
@@ -338,6 +338,12 @@ func processInternalRequest(
// apply batch request specific headers
// ---
for k, v := range ir.Headers {
// individual Authorization header keys don't have affect
// because the auth state is populated from the base event
if strings.EqualFold(k, "authorization") {
continue
}
r.Header.Set(k, v)
}
r.Header.Set("Content-Type", mw.FormDataContentType())
@@ -475,7 +481,7 @@ func extractPrefixedFiles(request *http.Request, prefixes ...string) (map[string
return result, nil
}
func prepareInternalAction(activeApp core.App, ir *core.InternalRequest, optNext func() error) (HandleFunc, map[string]string, bool) {
func prepareInternalAction(activeApp core.App, ir *core.InternalRequest, optNext func(data any) error) (HandleFunc, map[string]string, bool) {
full := strings.ToUpper(ir.Method) + " " + ir.URL
for re, actionFactory := range ValidBatchActions {
+13 -13
View File
@@ -294,9 +294,9 @@ func TestBatchRequest(t *testing.T) {
},
Body: strings.NewReader(`{
"requests": [
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}},
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3"},
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}}
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}, "headers": {"Authorization": "ignored"}},
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}}
]
}`),
ExpectedStatus: 200,
@@ -360,8 +360,8 @@ func TestBatchRequest(t *testing.T) {
Method: http.MethodPost,
URL: "/api/batch",
Headers: map[string]string{
// test@example.com, superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
// test@example.com, _superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: strings.NewReader(`{
"requests": [
@@ -431,8 +431,8 @@ func TestBatchRequest(t *testing.T) {
Method: http.MethodPost,
URL: "/api/batch",
Headers: map[string]string{
// test@example.com, superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
// test@example.com, _superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: strings.NewReader(`{
"requests": [
@@ -494,8 +494,8 @@ func TestBatchRequest(t *testing.T) {
]
}`),
Headers: map[string]string{
// test@example.com, superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
// test@example.com, _superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().Batch.Timeout = 1
@@ -620,8 +620,8 @@ func TestBatchRequest(t *testing.T) {
Method: http.MethodPost,
URL: "/api/batch",
Headers: map[string]string{
// test@example.com, superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
// test@example.com, _superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: strings.NewReader(`{
"requests": [
@@ -667,8 +667,8 @@ func TestBatchRequest(t *testing.T) {
Method: http.MethodPost,
URL: "/api/batch",
Headers: map[string]string{
// test@example.com, superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
// test@example.com, _superusers
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: strings.NewReader(`{
"requests": [
+23 -7
View File
@@ -45,7 +45,9 @@ func collectionsList(e *core.RequestEvent) error {
event.Result = result
return event.App.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListRequestEvent) error {
return e.JSON(http.StatusOK, e.Result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Result)
})
})
}
@@ -60,7 +62,9 @@ func collectionView(e *core.RequestEvent) error {
event.Collection = collection
return e.App.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -98,7 +102,9 @@ func collectionCreate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to create collection. Raw error: \n"+err.Error(), nil)
}
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -128,7 +134,9 @@ func collectionUpdate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to update collection. Raw error: \n"+err.Error(), nil)
}
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -159,7 +167,9 @@ func collectionDelete(e *core.RequestEvent) error {
return e.BadRequestError(msg, err)
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -182,9 +192,15 @@ func collectionTruncate(e *core.RequestEvent) error {
}
func collectionScaffolds(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]*core.Collection{
collections := map[string]*core.Collection{
core.CollectionTypeBase: core.NewBaseCollection(""),
core.CollectionTypeAuth: core.NewAuthCollection(""),
core.CollectionTypeView: core.NewViewCollection(""),
})
}
for _, c := range collections {
c.Id = "" // clear autogenerated 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)
}
+116 -4
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -40,7 +41,7 @@ func TestCollectionsImport(t *testing.T) {
URL: "/api/collections/import",
Body: strings.NewReader(`{"collections":[]}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -79,7 +80,7 @@ func TestCollectionsImport(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -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,
@@ -145,7 +212,7 @@ func TestCollectionsImport(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -199,7 +266,7 @@ func TestCollectionsImport(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -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 {
+146 -72
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"os"
"path/filepath"
@@ -42,7 +41,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -80,7 +79,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections?page=2&perPage=2&sort=-created",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -100,7 +99,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections?filter=invalidfield~'demo2'",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -111,7 +110,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections?filter=name~'demo'",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -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 {
@@ -165,7 +190,7 @@ func TestCollectionView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/missing",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -176,7 +201,7 @@ func TestCollectionView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/demo1",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -193,7 +218,7 @@ func TestCollectionView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/wsmn24bux7wo113",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -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 {
@@ -249,7 +300,7 @@ func TestCollectionDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/missing",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -260,7 +311,7 @@ func TestCollectionDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/demo5",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
@@ -283,7 +334,7 @@ func TestCollectionDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/9n89pl5vkct6330",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
@@ -306,7 +357,7 @@ func TestCollectionDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/" + core.CollectionNameMFAs,
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -326,7 +377,7 @@ func TestCollectionDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/demo2",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -346,7 +397,7 @@ func TestCollectionDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/view2",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -361,23 +412,30 @@ 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{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
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"},
},
}
@@ -416,7 +474,7 @@ func TestCollectionCreate(t *testing.T) {
URL: "/api/collections",
Body: strings.NewReader(``),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -443,7 +501,7 @@ func TestCollectionCreate(t *testing.T) {
URL: "/api/collections",
Body: strings.NewReader(`{"name":"demo1","type":"base","fields":[{"type":"text","name":""}]}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -469,7 +527,7 @@ func TestCollectionCreate(t *testing.T) {
URL: "/api/collections",
Body: strings.NewReader(`{"name":"new","type":"base","fields":[{"type":"text","id":"12345789","name":"test"}]}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -503,11 +561,11 @@ func TestCollectionCreate(t *testing.T) {
"emailChangeToken":{"duration":123},
"fields":[
{"type":"text","id":"12345789","name":"test"},
{"type":"text","name":"tokenKey","required":false,"min":10}
{"type":"text","name":"tokenKey","system":true,"required":false,"min":10}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -567,7 +625,7 @@ func TestCollectionCreate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -603,7 +661,7 @@ func TestCollectionCreate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -636,7 +694,7 @@ func TestCollectionCreate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -656,24 +714,31 @@ 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"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
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
@@ -689,7 +754,7 @@ func TestCollectionCreate(t *testing.T) {
"viewQuery":"invalid"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -718,13 +783,13 @@ func TestCollectionCreate(t *testing.T) {
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + `"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"view"`,
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
},
ExpectedEvents: map[string]int{
"*": 0,
@@ -758,7 +823,7 @@ func TestCollectionCreate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -793,14 +858,14 @@ func TestCollectionCreate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
demo1, err := app.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
demo1.AddIndex("exist_test", false, "created", "")
demo1.AddIndex("exist_test", false, "updated", "")
if err = app.Save(demo1); err != nil {
t.Fatal(err)
}
@@ -835,7 +900,7 @@ func TestCollectionCreate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -870,7 +935,7 @@ func TestCollectionCreate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -945,7 +1010,7 @@ func TestCollectionUpdate(t *testing.T) {
URL: "/api/collections/missing",
Body: strings.NewReader(`{}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -957,7 +1022,7 @@ func TestCollectionUpdate(t *testing.T) {
URL: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -978,24 +1043,31 @@ 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(`{}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
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)",
@@ -1006,7 +1078,7 @@ func TestCollectionUpdate(t *testing.T) {
"type":"auth"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1031,7 +1103,7 @@ func TestCollectionUpdate(t *testing.T) {
URL: "/api/collections/demo1",
Body: strings.NewReader(`{"name":"new"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1073,7 +1145,7 @@ func TestCollectionUpdate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1109,7 +1181,7 @@ func TestCollectionUpdate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1135,7 +1207,7 @@ func TestCollectionUpdate(t *testing.T) {
"passwordAuth":{"identityFields": ["missing"]}
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1165,7 +1237,7 @@ func TestCollectionUpdate(t *testing.T) {
"viewQuery":"invalid"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1193,7 +1265,7 @@ func TestCollectionUpdate(t *testing.T) {
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + `"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1232,7 +1304,7 @@ func TestCollectionUpdate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1261,14 +1333,14 @@ func TestCollectionUpdate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
demo1, err := app.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
demo1.AddIndex("exist_test", false, "created", "")
demo1.AddIndex("exist_test", false, "updated", "")
if err = app.Save(demo1); err != nil {
t.Fatal(err)
}
@@ -1301,7 +1373,7 @@ func TestCollectionUpdate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1332,7 +1404,7 @@ func TestCollectionUpdate(t *testing.T) {
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1405,10 +1477,12 @@ func TestCollectionScaffolds(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/meta/scaffolds",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":""`,
`"name":""`,
`"auth":{`,
`"base":{`,
`"view":{`,
@@ -1455,7 +1529,7 @@ func TestCollectionTruncate(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/demo5/truncate",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -1473,7 +1547,7 @@ func TestCollectionTruncate(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/demo3/truncate",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -1498,7 +1572,7 @@ func TestCollectionTruncate(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/view2/truncate",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+59
View File
@@ -0,0 +1,59 @@
package apis
import (
"net/http"
"slices"
"strings"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/routine"
)
// bindCronApi registers the crons api endpoint.
func bindCronApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup := rg.Group("/crons").Bind(RequireSuperuserAuth())
subGroup.GET("", cronsList)
subGroup.POST("/{id}", cronRun)
}
func cronsList(e *core.RequestEvent) error {
jobs := e.App.Cron().Jobs()
slices.SortStableFunc(jobs, func(a, b *cron.Job) int {
if strings.HasPrefix(a.Id(), "__pb") {
return 1
}
if strings.HasPrefix(b.Id(), "__pb") {
return -1
}
return strings.Compare(a.Id(), b.Id())
})
return e.JSON(http.StatusOK, jobs)
}
func cronRun(e *core.RequestEvent) error {
cronId := e.Request.PathValue("id")
var foundJob *cron.Job
jobs := e.App.Cron().Jobs()
for _, j := range jobs {
if j.Id() == cronId {
foundJob = j
break
}
}
if foundJob == nil {
return e.NotFoundError("Missing or invalid cron job", nil)
}
routine.FireAndForget(func() {
foundJob.Run()
})
return e.NoContent(http.StatusNoContent)
}
+149
View File
@@ -0,0 +1,149 @@
package apis_test
import (
"net/http"
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/spf13/cast"
)
func TestCronsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
URL: "/api/crons",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodGet,
URL: "/api/crons",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser (empty list)",
Method: http.MethodGet,
URL: "/api/crons",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Cron().RemoveAll()
},
ExpectedStatus: 200,
ExpectedContent: []string{`[]`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser",
Method: http.MethodGet,
URL: "/api/crons",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`{"id":"__pbLogsCleanup__","expression":"0 */6 * * *"}`,
`{"id":"__pbDBOptimize__","expression":"0 0 * * *"}`,
`{"id":"__pbMFACleanup__","expression":"0 * * * *"}`,
`{"id":"__pbOTPCleanup__","expression":"0 * * * *"}`,
},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestCronsRun(t *testing.T) {
t.Parallel()
beforeTestFunc := func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Cron().Add("test", "* * * * *", func() {
app.Store().Set("testJobCalls", cast.ToInt(app.Store().Get("testJobCalls"))+1)
})
}
expectedCalls := func(expected int) func(t testing.TB, app *tests.TestApp, res *http.Response) {
return func(t testing.TB, app *tests.TestApp, res *http.Response) {
total := cast.ToInt(app.Store().Get("testJobCalls"))
if total != expected {
t.Fatalf("Expected total testJobCalls %d, got %d", expected, total)
}
}
}
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
URL: "/api/crons/test",
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodPost,
URL: "/api/crons/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0),
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser (missing job)",
Method: http.MethodPost,
URL: "/api/crons/missing",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0),
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser (existing job)",
Method: http.MethodPost,
URL: "/api/crons/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(1),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
-137
View File
@@ -1,137 +0,0 @@
package apis
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
const installerParam = "pbinstal"
var wildcardPlaceholderRegex = regexp.MustCompile(`/{.+\.\.\.}$`)
func stripWildcard(pattern string) string {
return wildcardPlaceholderRegex.ReplaceAllString(pattern, "")
}
// installerRedirect redirects the user to the installer dashboard UI page
// when the application needs some preliminary configurations to be done.
func installerRedirect(app core.App, cpPath string) func(*core.RequestEvent) error {
// note: to avoid locks contention it is not concurrent safe but it
// is expected to be updated only once during initialization
var hasSuperuser bool
// strip named wildcard
cpPath = stripWildcard(cpPath)
updateHasSuperuser := func(app core.App) error {
total, err := app.CountRecords(core.CollectionNameSuperusers)
if err != nil {
return err
}
hasSuperuser = total > 0
return nil
}
// load initial state on app init
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
err := e.Next()
if err != nil {
return err
}
err = updateHasSuperuser(e.App)
if err != nil {
return fmt.Errorf("failed to check for existing superuser: %w", err)
}
return nil
})
// update on superuser create
app.OnRecordCreateRequest(core.CollectionNameSuperusers).BindFunc(func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
if !hasSuperuser {
hasSuperuser = true
}
return nil
})
return func(e *core.RequestEvent) error {
if hasSuperuser {
return e.Next()
}
isAPI := strings.HasPrefix(e.Request.URL.Path, "/api/")
isControlPanel := strings.HasPrefix(e.Request.URL.Path, cpPath)
wildcard := e.Request.PathValue(StaticWildcardParam)
// skip redirect checks for API and non-root level dashboard index.html requests (css, images, etc.)
if isAPI || (isControlPanel && wildcard != "" && wildcard != router.IndexPage) {
return e.Next()
}
// check again in case the superuser was created by some other process
if err := updateHasSuperuser(e.App); err != nil {
return err
}
if hasSuperuser {
return e.Next()
}
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
// redirect to the installer page
if !hasInstallerParam {
return e.Redirect(http.StatusTemporaryRedirect, cpPath+"?"+installerParam+"#")
}
return e.Next()
}
}
// dashboardRemoveInstallerParam redirects to a non-installer
// query param in case there is already a superuser created.
//
// Note: intended to be registered only for the dashboard route
// to prevent excessive checks for every other route in installerRedirect.
func dashboardRemoveInstallerParam() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
if !hasInstallerParam {
return e.Next() // nothing to remove
}
// clear installer param
total, _ := e.App.CountRecords(core.CollectionNameSuperusers)
if total > 0 {
return e.Redirect(http.StatusTemporaryRedirect, "?")
}
return e.Next()
}
}
// dashboardCacheControl adds default Cache-Control header for all
// dashboard UI resources (ignoring the root index.html path)
func dashboardCacheControl() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
if e.Request.PathValue(StaticWildcardParam) != "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
return e.Next()
}
}
+22 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"runtime"
"time"
@@ -13,19 +14,30 @@ import (
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/spf13/cast"
"golang.org/x/sync/semaphore"
"golang.org/x/sync/singleflight"
)
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.
func bindFileApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
maxWorkers := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WORKERS"))
if maxWorkers <= 0 {
maxWorkers = int64(runtime.NumCPU() + 2) // the value is arbitrary chosen and may change in the future
}
maxWait := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WAIT"))
if maxWait <= 0 {
maxWait = 60
}
api := fileApi{
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 2)), // the value is arbitrary chosen and may change in the future
thumbGenPending: new(singleflight.Group),
thumbGenMaxWait: 60 * time.Second,
thumbGenSem: semaphore.NewWeighted(maxWorkers),
thumbGenMaxWait: time.Duration(maxWait) * time.Second,
}
sub := rg.Group("/files")
@@ -60,10 +72,11 @@ func (api *fileApi) fileToken(e *core.RequestEvent) error {
event := new(core.FileTokenRequestEvent)
event.RequestEvent = e
event.Token = token
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})
})
})
}
@@ -179,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)
}
+4 -4
View File
@@ -49,7 +49,7 @@ func TestFileToken(t *testing.T) {
Method: http.MethodPost,
URL: "/api/files/token",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -65,7 +65,7 @@ func TestFileToken(t *testing.T) {
Method: http.MethodPost,
URL: "/api/files/token",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileTokenRequest().BindFunc(func(e *core.FileTokenRequestEvent) error {
@@ -273,7 +273,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "protected file - superuser with expired file token",
Method: http.MethodGet,
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.hTNDzikwJdcoWrLnRnp7xbaifZ2vuYZ0oOYRHtJfnk4",
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.nqqtqpPhxU0045F4XP_ruAkzAidYBc5oPy9ErN3XBq0",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
@@ -281,7 +281,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "protected file - superuser with valid file token",
Method: http.MethodGet,
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
+1 -1
View File
@@ -51,7 +51,7 @@ func TestHealthAPI(t *testing.T) {
Method: http.MethodGet,
URL: "/api/health",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
+88
View File
@@ -0,0 +1,88 @@
package apis
import (
"database/sql"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils"
)
// DefaultInstallerFunc is the default PocketBase installer function.
//
// It will attempt to open a link in the browser (with a short-lived auth
// token for the systemSuperuser) to the installer UI so that users can
// create their own custom superuser record.
//
// See https://github.com/pocketbase/pocketbase/discussions/5814.
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
if err != nil {
return err
}
// launch url (ignore errors and always print a help text as fallback)
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
_ = osutils.LaunchURL(url)
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
color.New(color.Bold).Add(color.FgCyan).Println(url)
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0])
return nil
}
func loadInstaller(
app core.App,
baseURL string,
installerFunc func(app core.App, systemSuperuser *core.Record, baseURL string) error,
) error {
if installerFunc == nil || !needInstallerSuperuser(app) {
return nil
}
superuser, err := findOrCreateInstallerSuperuser(app)
if err != nil {
return err
}
return installerFunc(app, superuser, baseURL)
}
func needInstallerSuperuser(app core.App) bool {
total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
"email": core.DefaultInstallerEmail,
}))
return err == nil && total == 0
}
func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
col, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return nil, err
}
record, err := app.FindAuthRecordByEmail(col, core.DefaultInstallerEmail)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
record = core.NewRecord(col)
record.SetEmail(core.DefaultInstallerEmail)
record.SetRandomPassword()
err = app.Save(record)
if err != nil {
return nil, err
}
}
return record, nil
}
+6 -6
View File
@@ -36,7 +36,7 @@ func TestLogsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/logs",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubLogsData(app); err != nil {
@@ -59,7 +59,7 @@ func TestLogsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/logs?filter=data.status>200",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubLogsData(app); err != nil {
@@ -111,7 +111,7 @@ func TestLogView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubLogsData(app); err != nil {
@@ -127,7 +127,7 @@ func TestLogView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubLogsData(app); err != nil {
@@ -175,7 +175,7 @@ func TestLogsStats(t *testing.T) {
Method: http.MethodGet,
URL: "/api/logs/stats",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubLogsData(app); err != nil {
@@ -192,7 +192,7 @@ func TestLogsStats(t *testing.T) {
Method: http.MethodGet,
URL: "/api/logs/stats?filter=data.status>200",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubLogsData(app); err != nil {
+33 -40
View File
@@ -48,7 +48,6 @@ const (
DefaultRequireGuestOnlyMiddlewareId = "pbRequireGuestOnly"
DefaultRequireAuthMiddlewareId = "pbRequireAuth"
DefaultRequireSuperuserAuthMiddlewareId = "pbRequireSuperuserAuth"
DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId = "pbRequireSuperuserAuthOnlyIfAny"
DefaultRequireSuperuserOrOwnerAuthMiddlewareId = "pbRequireSuperuserOrOwnerAuth"
DefaultRequireSameCollectionContextAuthMiddlewareId = "pbRequireSameCollectionContextAuth"
)
@@ -110,31 +109,6 @@ func RequireSuperuserAuth() *hook.Handler[*core.RequestEvent] {
}
}
// RequireSuperuserAuthOnlyIfAny middleware requires a request to have
// a valid superuser Authorization header ONLY if the application has
// at least 1 existing superuser.
func RequireSuperuserAuthOnlyIfAny() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId,
Func: func(e *core.RequestEvent) error {
if e.HasSuperuserAuth() {
return e.Next()
}
totalSuperusers, err := e.App.CountRecords(core.CollectionNameSuperusers)
if err != nil {
return e.InternalServerError("Failed to fetch superusers info.", err)
}
if totalSuperusers == 0 {
return e.Next()
}
return requireAuth(core.CollectionNameSuperusers)(e)
},
}
}
// RequireSuperuserOrOwnerAuth middleware requires a request to have
// a valid superuser or regular record owner Authorization header set.
//
@@ -196,7 +170,9 @@ func RequireSameCollectionContextAuth(collectionPathParam string) *hook.Handler[
// loadAuthToken attempts to load the auth context based on the "Authorization: TOKEN" header value.
//
// This middleware does nothing in case of missing, invalid or expired token.
// This middleware does nothing in case of:
// - missing, invalid or expired token
// - e.Auth is already loaded by another middleware
//
// This middleware is registered by default for all routes.
//
@@ -207,6 +183,11 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
Id: DefaultLoadAuthTokenMiddlewareId,
Priority: DefaultLoadAuthTokenMiddlewarePriority,
Func: func(e *core.RequestEvent) error {
// already loaded by another middleware
if e.Auth != nil {
return e.Next()
}
token := getAuthTokenFromRequest(e)
if token == "" {
return e.Next()
@@ -246,9 +227,15 @@ func wwwRedirect(redirectHosts []string) *hook.Handler[*core.RequestEvent] {
host := e.Request.Host
if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, redirectHosts) {
// note: e.Request.URL.Scheme would be empty
schema := "http://"
if e.IsTLS() {
schema = "https://"
}
return e.Redirect(
http.StatusTemporaryRedirect,
(e.Request.URL.Scheme + "://" + host[4:] + e.Request.RequestURI),
(schema + host[4:] + e.Request.RequestURI),
)
}
@@ -378,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 {
@@ -410,17 +411,9 @@ func logRequest(event *core.RequestEvent, err error) {
}
if event.App.Settings().Logs.LogIP {
var userIP string
if len(event.App.Settings().TrustedProxy.Headers) > 0 {
userIP = event.RealIP()
} else {
// fallback to the legacy behavior (it is "safe" since it is only for log purposes)
userIP = cutStr(event.UnsafeRealIP(), 50)
}
attrs = append(
attrs,
slog.String("userIP", userIP),
slog.String("userIP", event.RealIP()),
slog.String("remoteIP", event.RemoteIP()),
)
}
+1 -1
View File
@@ -18,7 +18,7 @@ const (
DefaultBodyLimitMiddlewarePriority = DefaultRateLimitMiddlewarePriority + 10
)
// BodyLimit returns a middleware function that changes the default request body size limit.
// BodyLimit returns a middleware handler that changes the default request body size limit.
//
// If limitBytes <= 0, no limit is applied.
//
+21 -5
View File
@@ -13,6 +13,7 @@ package apis
// -------------------------------------------------------------------
import (
"log"
"net/http"
"regexp"
"strconv"
@@ -124,8 +125,8 @@ var DefaultCORSConfig = CORSConfig{
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}
// CORSWithConfig returns a CORS middleware with config.
func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
// CORS returns a CORS middleware.
func CORS(config CORSConfig) *hook.Handler[*core.RequestEvent] {
// Defaults
if len(config.AllowOrigins) == 0 {
config.AllowOrigins = DefaultCORSConfig.AllowOrigins
@@ -134,13 +135,26 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
config.AllowMethods = DefaultCORSConfig.AllowMethods
}
allowOriginPatterns := []string{}
allowOriginPatterns := make([]*regexp.Regexp, 0, len(config.AllowOrigins))
for _, origin := range config.AllowOrigins {
if origin == "*" {
continue // "*" is handled differently and does not need regexp
}
pattern := regexp.QuoteMeta(origin)
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
pattern = strings.ReplaceAll(pattern, "\\?", ".")
pattern = "^" + pattern + "$"
allowOriginPatterns = append(allowOriginPatterns, pattern)
re, err := regexp.Compile(pattern)
if err != nil {
// This is to preserve previous behaviour - invalid patterns were just ignored.
// If we would turn this to panic, users with invalid patterns
// would have applications crashing in production due unrecovered panic.
log.Println("invalid AllowOrigins pattern", origin)
continue
}
allowOriginPatterns = append(allowOriginPatterns, re)
}
allowMethods := strings.Join(config.AllowMethods, ",")
@@ -210,7 +224,7 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
}
if checkPatterns {
for _, re := range allowOriginPatterns {
if match, _ := regexp.MatchString(re, origin); match {
if match := re.MatchString(origin); match {
allowOrigin = origin
break
}
@@ -272,6 +286,7 @@ func matchSubdomain(domain, pattern string) bool {
if !matchScheme(domain, pattern) {
return false
}
didx := strings.Index(domain, "://")
pidx := strings.Index(pattern, "://")
if didx == -1 || pidx == -1 {
@@ -307,5 +322,6 @@ func matchSubdomain(domain, pattern string) bool {
return false
}
}
return false
}
+20 -17
View File
@@ -221,23 +221,26 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
}
}
func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
if w.wroteHeader {
w.ResponseWriter.WriteHeader(w.code)
}
rw := w.ResponseWriter
for {
switch rf := rw.(type) {
case io.ReaderFrom:
return rf.ReadFrom(r)
case router.RWUnwrapper:
rw = rf.Unwrap()
default:
return io.Copy(w.ResponseWriter, r)
}
}
}
// Note: Disable the implementation for now because in case the platform
// supports the sendfile fast-path it won't run gzipResponseWriter.Write,
// preventing compression on the fly.
//
// func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
// if w.wroteHeader {
// w.ResponseWriter.WriteHeader(w.code)
// }
// rw := w.ResponseWriter
// for {
// switch rf := rw.(type) {
// case io.ReaderFrom:
// return rf.ReadFrom(r)
// case router.RWUnwrapper:
// rw = rf.Unwrap()
// default:
// return io.Copy(w.ResponseWriter, r)
// }
// }
// }
func (w *gzipResponseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
+67 -9
View File
@@ -32,9 +32,12 @@ func rateLimit() *hook.Handler[*core.RequestEvent] {
return e.Next()
}
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(defaultRateLimitLabels(e))
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(
defaultRateLimitLabels(e),
defaultRateLimitAudience(e)...,
)
if ok {
err := checkRateLimit(e, e.Request.Pattern, rule)
err := checkRateLimit(e, rule.Label+rule.Audience, rule)
if err != nil {
return err
}
@@ -94,9 +97,9 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
}
labels = append(labels, defaultRateLimitLabels(e)...)
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels)
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels, defaultRateLimitAudience(e)...)
if ok {
return checkRateLimit(e, rtId, rule)
return checkRateLimit(e, rtId+rule.Audience, rule)
}
return nil
@@ -104,11 +107,46 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
// -------------------------------------------------------------------
// @todo consider exporting as RateLimit helper?
// @todo consider exporting as helper?
//
//nolint:unused
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
if !ok || rateLimiters == nil {
return false
}
rt, ok := rateLimiters.GetOk(rtId)
if !ok || rt == nil {
return false
}
client, ok := rt.getClient(e.RealIP())
if !ok || client == nil {
return false
}
return client.available <= 0 && time.Now().Unix()-client.lastConsume < client.interval
}
// @todo consider exporting as helper?
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
switch rule.Audience {
case core.RateLimitRuleAudienceAll:
// valid for both guest and regular users
case core.RateLimitRuleAudienceGuest:
if e.Auth != nil {
return nil
}
case core.RateLimitRuleAudienceAuth:
if e.Auth == nil {
return nil
}
}
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
return initRateLimitersStore(e.App)
}).(*store.Store[*rateLimiter])
}).(*store.Store[string, *rateLimiter])
if rateLimiters == nil {
e.App.Logger().Warn("Failed to retrieve app rate limiters store")
return nil
@@ -139,6 +177,17 @@ func skipRateLimit(e *core.RequestEvent) bool {
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
}
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
var defaultGuestAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceGuest}
func defaultRateLimitAudience(e *core.RequestEvent) []string {
if e.Auth != nil {
return defaultAuthAudience
}
return defaultGuestAudience
}
func defaultRateLimitLabels(e *core.RequestEvent) []string {
return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
}
@@ -149,9 +198,9 @@ func destroyRateLimitersStore(app core.App) {
app.Store().Remove(rateLimitersStoreKey)
}
func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
func initRateLimitersStore(app core.App) *store.Store[string, *rateLimiter] {
app.Cron().Add(rateLimitersCronKey, "2 * * * *", func() { // offset a little since too many cleanup tasks execute at 00
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[*rateLimiter])
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
if !ok {
return
}
@@ -176,7 +225,7 @@ func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
},
})
return store.New[*rateLimiter](nil)
return store.New[string, *rateLimiter](nil)
}
func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec int64) *rateLimiter {
@@ -199,6 +248,15 @@ type rateLimiter struct {
sync.RWMutex
}
//nolint:unused
func (rt *rateLimiter) getClient(key string) (*fixedWindow, bool) {
rt.RLock()
client, ok := rt.clients[key]
rt.RUnlock()
return client, ok
}
func (rt *rateLimiter) isAllowed(key string) bool {
// lock only reads to minimize locks contention
rt.RLock()
+76 -20
View File
@@ -31,6 +31,18 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
MaxRequests: 1,
Duration: 1,
},
{
Label: "/rate/guest",
MaxRequests: 1,
Duration: 1,
Audience: core.RateLimitRuleAudienceGuest,
},
{
Label: "/rate/auth",
MaxRequests: 1,
Duration: 1,
Audience: core.RateLimitRuleAudienceAuth,
},
}
pbRouter, err := apis.NewRouter(app)
@@ -48,6 +60,12 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
pbRouter.GET("/rate/b", func(e *core.RequestEvent) error {
return e.String(200, "b")
})
pbRouter.GET("/rate/guest", func(e *core.RequestEvent) error {
return e.String(200, "guest")
})
pbRouter.GET("/rate/auth", func(e *core.RequestEvent) error {
return e.String(200, "auth")
})
mux, err := pbRouter.BuildMux()
if err != nil {
@@ -57,30 +75,53 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
scenarios := []struct {
url string
wait float64
authenticated bool
expectedStatus int
}{
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/norate", 0, false, 200},
{"/rate/a", 0, 200},
{"/rate/a", 0, 200},
{"/rate/a", 0, 429},
{"/rate/a", 0, 429},
{"/rate/a", 1.1, 200},
{"/rate/a", 0, 200},
{"/rate/a", 0, 429},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
{"/rate/a", 0, false, 429},
{"/rate/a", 1.1, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
{"/rate/b", 0, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 429},
{"/rate/b", 1.1, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 200},
{"/rate/b", 0, 429},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429},
{"/rate/b", 1.1, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429},
// "auth" with guest (should fallback to the /rate/ rule)
{"/rate/auth", 0, false, 200},
{"/rate/auth", 0, false, 200},
{"/rate/auth", 0, false, 429},
{"/rate/auth", 0, false, 429},
// "auth" rule with regular user (should match the /rate/auth rule)
{"/rate/auth", 0, true, 200},
{"/rate/auth", 0, true, 429},
{"/rate/auth", 0, true, 429},
// "guest" with guest (should match the /rate/guest rule)
{"/rate/guest", 0, false, 200},
{"/rate/guest", 0, false, 429},
{"/rate/guest", 0, false, 429},
// "guest" rule with regular user (should fallback to the /rate/ rule)
{"/rate/guest", 1, true, 200},
{"/rate/guest", 0, true, 200},
{"/rate/guest", 0, true, 429},
{"/rate/guest", 0, true, 429},
}
for _, s := range scenarios {
@@ -91,6 +132,21 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", s.url, nil)
if s.authenticated {
auth, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
token, err := auth.NewAuthToken()
if err != nil {
t.Fatal(err)
}
req.Header.Add("Authorization", token)
}
mux.ServeHTTP(rec, req)
result := rec.Result()
+9 -98
View File
@@ -75,7 +75,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 400,
@@ -148,7 +148,7 @@ func TestRequireAuth(t *testing.T) {
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
@@ -197,7 +197,7 @@ func TestRequireAuth(t *testing.T) {
URL: "/my/test",
Headers: map[string]string{
// superuser
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
@@ -214,7 +214,7 @@ func TestRequireAuth(t *testing.T) {
URL: "/my/test",
Headers: map[string]string{
// superuser
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
@@ -253,7 +253,7 @@ func TestRequireSuperuserAuth(t *testing.T) {
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.a668tes0bS6FU-OOlXMoRrdd57a_oldIPd5b0Gv_RYw",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
@@ -285,7 +285,7 @@ func TestRequireSuperuserAuth(t *testing.T) {
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
@@ -302,95 +302,6 @@ func TestRequireSuperuserAuth(t *testing.T) {
}
}
func TestRequireSuperuserAuthOnlyIfAny(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest (while having at least 1 existing superuser)",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "guest (while having 0 existing superusers)",
Method: http.MethodGet,
URL: "/my/test",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.a668tes0bS6FU-OOlXMoRrdd57a_oldIPd5b0Gv_RYw",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid regular user token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid superuser auth token",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
t.Parallel()
@@ -413,7 +324,7 @@ func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.a668tes0bS6FU-OOlXMoRrdd57a_oldIPd5b0Gv_RYw",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
@@ -491,7 +402,7 @@ func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
Method: http.MethodGet,
URL: "/my/test/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
@@ -609,7 +520,7 @@ func TestRequireSameCollectionContextAuth(t *testing.T) {
Method: http.MethodGet,
URL: "/my/test/_pb_users_auth_",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
+86 -49
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 {
@@ -160,7 +159,10 @@ type realtimeSubscribeForm struct {
func (form *realtimeSubscribeForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)),
validation.Field(&form.Subscriptions, validation.Length(0, 1000)),
validation.Field(&form.Subscriptions,
validation.Length(0, 1000),
validation.Each(validation.Length(0, 2500)),
),
)
}
@@ -211,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)
})
})
}
@@ -239,8 +243,8 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
return group.Wait()
}
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
@@ -252,7 +256,7 @@ func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
if clientAuth != nil &&
clientAuth.Id == authModel.PK() &&
clientAuth.Collection().Name == authModel.TableName() {
app.SubscriptionsBroker().Unregister(client.Id())
client.Unset(RealtimeClientAuthKey)
}
}
@@ -290,7 +294,7 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelEvent) error {
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil && collection.IsAuth() {
if err := realtimeUnregisterClientsByAuth(e.App, e.Model); err != nil {
if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth model",
slog.Any("id", e.Model.PK()),
@@ -350,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",
@@ -369,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()),
)
}
@@ -392,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",
@@ -412,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
@@ -441,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.
@@ -457,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")
@@ -479,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
@@ -499,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,
@@ -512,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
@@ -538,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)
}
}
@@ -619,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 {
@@ -656,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 {
@@ -682,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
@@ -719,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})
@@ -736,5 +773,5 @@ func realtimeCanAccessRecord(
err = q.Limit(1).Row(&exists)
return err == nil && exists
return err == nil && exists > 0
}
+364 -15
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) {
@@ -183,6 +187,54 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient()
},
},
{
Name: "existing client with invalid topic length",
Method: http.MethodPost,
URL: "/api/realtime",
Body: strings.NewReader(`{
"clientId": "` + client.Id() + `",
"subscriptions": ["abc", "` + strings.Repeat("a", 2501) + `"]
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
resetClient()
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"subscriptions":{"1":{"code":"validation_length_too_long"`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "existing client with valid topic length",
Method: http.MethodPost,
URL: "/api/realtime",
Body: strings.NewReader(`{
"clientId": "` + client.Id() + `",
"subscriptions": ["abc", "` + strings.Repeat("a", 2500) + `"]
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"*": 0,
"OnRealtimeSubscribeRequest": 1,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(client.Subscriptions()) != 2 {
t.Errorf("Expected %d subscriptions, got %d", 2, len(client.Subscriptions()))
}
if client.HasSubscription("test0") {
t.Errorf("Expected old subscriptions to be replaced")
}
resetClient()
},
},
{
Name: "existing client - empty subscriptions",
Method: http.MethodPost,
@@ -238,7 +290,7 @@ func TestRealtimeSubscribe(t *testing.T) {
URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -379,26 +431,51 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
// init realtime handlers
apis.NewRouter(testApp)
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.RealtimeClientAuthKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client1)
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client2)
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
testApp.SubscriptionsBroker().Register(client3)
// mock delete event
e := new(core.ModelEvent)
e.App = testApp
e.Type = core.ModelEventTypeDelete
e.Context = context.Background()
e.Model = authRecord
e.Model = authRecord1
testApp.OnModelAfterDeleteSuccess().Trigger(e)
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
t.Fatalf("Expected no subscription clients, found %d", total)
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
}
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
}
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
}
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
}
}
@@ -476,17 +553,30 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
// init realtime handlers
apis.NewRouter(testApp)
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.RealtimeClientAuthKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client1)
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client2)
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
testApp.SubscriptionsBroker().Register(client3)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp, "test@example.com")
customUser, err := findCustomUserByEmail(testApp, authRecord1.Email())
if err != nil {
t.Fatal(err)
}
@@ -496,8 +586,20 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
t.Fatal(err)
}
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
t.Fatalf("Expected no subscription clients, found %d", total)
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
}
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
}
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
}
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
}
}
@@ -534,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])
}
}
}
})
}
}
+5 -4
View File
@@ -38,15 +38,16 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
event.NewEmail = newEmail
return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
authRecord.Set(core.FieldNameEmail, e.NewEmail)
authRecord.Set(core.FieldNameVerified, true)
authRecord.RefreshTokenKey() // invalidate old tokens
e.Record.SetEmail(e.NewEmail)
e.Record.SetVerified(true)
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
}
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)
})
})
}
+28 -1
View File
@@ -49,7 +49,7 @@ func TestRecordRequestEmailChange(t *testing.T) {
URL: "/api/collections/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
@@ -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)
+3 -3
View File
@@ -47,7 +47,7 @@ func TestRecordAuthImpersonate(t *testing.T) {
Method: http.MethodPost,
URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -71,7 +71,7 @@ func TestRecordAuthImpersonate(t *testing.T) {
Method: http.MethodPost,
URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: strings.NewReader(`{"duration":-1}`),
ExpectedStatus: 400,
@@ -86,7 +86,7 @@ func TestRecordAuthImpersonate(t *testing.T) {
Method: http.MethodPost,
URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: strings.NewReader(`{"duration":100}`),
ExpectedStatus: 200,
+20 -11
View File
@@ -1,6 +1,7 @@
package apis
import (
"database/sql"
"errors"
"fmt"
"net/http"
@@ -32,12 +33,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
}
record, err := e.App.FindAuthRecordByEmail(collection, form.Email)
if err != nil {
// eagerly write a dummy 200 response as a very rudimentary user emails enumeration protection
e.JSON(http.StatusOK, map[string]string{
"otpId": core.GenerateDefaultRandomId(),
})
return fmt.Errorf("failed to fetch %s record with email %s: %w", collection.Name, form.Email, err)
// ignore not found errors to allow custom record find implementations
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return e.InternalServerError("", err)
}
event := new(core.RecordCreateOTPRequestEvent)
@@ -46,7 +45,18 @@ func recordRequestOTP(e *core.RequestEvent) error {
event.Collection = collection
event.Record = record
originalApp := e.App
return e.App.OnRecordRequestOTPRequest().Trigger(event, func(e *core.RecordCreateOTPRequestEvent) error {
if e.Record == nil {
// write a dummy 200 response as a very rudimentary emails enumeration "protection"
e.JSON(http.StatusOK, map[string]string{
"otpId": core.GenerateDefaultRandomId(),
})
return fmt.Errorf("missing or invalid %s OTP auth record with email %s", collection.Name, form.Email)
}
var otp *core.OTP
// limit the new OTP creations for a single user
@@ -90,17 +100,16 @@ func recordRequestOTP(e *core.RequestEvent) error {
// send OTP email
// (in the background as a very basic timing attacks and emails enumeration protection)
// ---
app := e.App
routine.FireAndForget(func() {
err = mails.SendRecordOTP(app, e.Record, otp.Id, e.Password)
err = mails.SendRecordOTP(originalApp, e.Record, otp.Id, e.Password)
if err != nil {
app.Logger().Error("Failed to send OTP email", "error", errors.Join(err, e.App.Delete(otp)))
originalApp.Logger().Error("Failed to send OTP email", "error", errors.Join(err, originalApp.Delete(otp)))
}
})
}
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})
})
})
}
+88 -3
View File
@@ -86,7 +86,10 @@ func TestRecordRequestOTP(t *testing.T) {
ExpectedContent: []string{
`"otpId":"`, // some fake random generated string
},
ExpectedEvents: map[string]int{"*": 0},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordRequestOTPRequest": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 0 {
t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
@@ -137,6 +140,57 @@ func TestRecordRequestOTP(t *testing.T) {
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 2, // + 1 for the OTP update after the email send
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 2,
// OTP update
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
}
// ensure that sentTo is set
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
if err != nil || len(otps) != 1 {
t.Fatalf("Expected to find 1 OTP with sentTo %q, found %d", "test@example.com", len(otps))
}
},
},
{
Name: "existing auth record with intercepted email (with < 9 non-expired)",
Method: http.MethodPost,
URL: "/api/collections/users/request-otp",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// prevent email sent
app.OnMailerRecordOTPSend("users").BindFunc(func(e *core.MailerRecordEvent) error {
return nil
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"otpId":"`,
},
NotExpectedContent: []string{
`"otpId":"otp_`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordRequestOTPRequest": 1,
"OnMailerRecordOTPSend": 1,
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
@@ -144,8 +198,14 @@ func TestRecordRequestOTP(t *testing.T) {
"OnRecordValidate": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
if app.TestMailer.TotalSend() != 0 {
t.Fatalf("Expected 0 emails, got %d", app.TestMailer.TotalSend())
}
// ensure that there is no OTP with user email as sentTo
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
if err != nil || len(otps) != 0 {
t.Fatalf("Expected to find 0 OTPs with sentTo %q, found %d", "test@example.com", len(otps))
}
},
},
@@ -187,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
// -----------------------------------------------------------
+5 -3
View File
@@ -47,14 +47,16 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
}
}
err = form.app.Save(authRecord)
err = e.App.Save(authRecord)
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to set new password.", err))
}
form.app.Store().Remove(getPasswordResetResendKey(authRecord))
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
return e.NoContent(http.StatusNoContent)
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"
@@ -186,11 +185,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
t.Fatal("Expected the user to be unverified")
}
oldTokenKey := user.TokenKey()
// manually change the email to check whether the verified state will be updated
user.SetEmail("test_update@example.com")
if err := app.Save(user); err != nil {
if err = app.Save(user); err != nil {
t.Fatalf("Failed to update user test email: %v", err)
}
// resave with the old token key since the email change above
// would change it and will make the password token invalid
user.SetTokenKey(oldTokenKey)
if err = app.Save(user); err != nil {
t.Fatalf("Failed to restore original user tokenKey: %v", err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByToken(
@@ -273,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(`{
@@ -283,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
// -----------------------------------------------------------
+16 -10
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"testing"
@@ -26,7 +25,7 @@ func TestRecordAuthRefresh(t *testing.T) {
Method: http.MethodPost,
URL: "/api/collections/users/auth-refresh",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
@@ -130,23 +129,30 @@ func TestRecordAuthRefresh(t *testing.T) {
},
},
{
Name: "OnRecordAfterAuthRefreshRequest error response",
Name: "OnRecordAuthRefreshRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-refresh?expand=rel,missing",
URL: "/api/collections/users/auth-refresh",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordAuthRefreshRequest().BindFunc(func(e *core.RecordAuthRefreshRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthRefreshRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordAuthRefreshRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+8 -8
View File
@@ -42,19 +42,19 @@ func recordConfirmVerification(e *core.RequestEvent) error {
event.Record = record
return e.App.OnRecordConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationRequestEvent) error {
if wasVerified {
return e.NoContent(http.StatusNoContent)
}
if !wasVerified {
e.Record.SetVerified(true)
e.Record.SetVerified(true)
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
}
}
e.App.Store().Remove(getVerificationResendKey(e.Record))
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
+14 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -144,7 +143,7 @@ func TestRecordConfirmVerification(t *testing.T) {
},
},
{
Name: "OnRecordAfterConfirmVerificationRequest error response",
Name: "OnRecordConfirmVerificationRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
@@ -152,15 +151,22 @@ func TestRecordConfirmVerification(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmVerificationRequest().BindFunc(func(e *core.RecordConfirmVerificationRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmVerificationRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmVerificationRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -68,7 +68,9 @@ func recordRequestVerification(e *core.RequestEvent) error {
})
})
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -118,6 +118,30 @@ func TestRecordRequestVerification(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestVerificationRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-verification",
Body: strings.NewReader(`{"email":"test@example.com"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestVerificationRequest().BindFunc(func(e *core.RecordRequestVerificationRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestVerificationRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+52 -39
View File
@@ -2,21 +2,21 @@ package apis
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
@@ -35,6 +35,8 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
fallbackAuthRecord = e.Auth
}
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
form := new(recordOAuth2LoginForm)
form.collection = collection
if err = e.BindBody(form); err != nil {
@@ -96,6 +98,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
"provider": form.Provider,
"providerId": authUser.Id,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return e.InternalServerError("Failed OAuth2 relation check.", err)
}
switch {
case err == nil && externalAuthRel != nil:
authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
@@ -107,7 +113,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
authRecord = fallbackAuthRecord
case authUser.Email != "":
// look for an existing auth record by the external auth record's email
authRecord, _ = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
authRecord, err = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return e.InternalServerError("Failed OAuth2 auth record check.", err)
}
}
// ---------------------------------------------------------------
@@ -127,13 +136,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
}
meta := struct {
*auth.AuthUser
IsNew bool `json:"isNew"`
}{
AuthUser: e.OAuth2User,
IsNew: e.IsNewRecord,
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
meta := map[string]any{}
rawOAuth2User, err := json.Marshal(e.OAuth2User)
if err != nil {
return err
}
err = json.Unmarshal(rawOAuth2User, &meta)
if err != nil {
return err
}
meta["isNew"] = e.IsNewRecord
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
})
@@ -168,7 +181,7 @@ type recordOAuth2LoginForm struct {
func (form *recordOAuth2LoginForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required),
validation.Field(&form.RedirectURL, validation.Required),
)
@@ -179,7 +192,7 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
_, ok := form.collection.OAuth2.GetProviderConfig(name)
if !ok {
return validation.NewError("validation_invalid_provider", fmt.Sprintf("Provider with name %q is missing or is not enabled.", name)).
return validation.NewError("validation_invalid_provider", "Provider with name {{.name}} is missing or is not enabled.").
SetParams(map[string]any{"name": name})
}
@@ -188,10 +201,20 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
// ensure that username is unique
checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
if checkUnique {
if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
return false // already exist
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
if hasUniqueue {
var expr dbx.Expression
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
} else {
expr = dbx.HashExp{"username": username}
}
var exists int
_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
if exists > 0 {
return false
}
}
@@ -215,12 +238,10 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload = map[string]any{}
}
payload[core.FieldNameEmail] = e.OAuth2User.Email
// set a random password if none is set
if v, _ := payload[core.FieldNamePassword].(string); v == "" {
payload[core.FieldNamePassword] = security.RandomString(30)
payload[core.FieldNamePassword+"Confirm"] = payload[core.FieldNamePassword]
// assign the OAuth2 user email only if the user hasn't submitted one
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
payload[core.FieldNameEmail] = e.OAuth2User.Email
}
// map known fields (unless the field was explicitly submitted as part of CreateData)
@@ -238,7 +259,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
}
if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
// no existing OAuth2 mapping
// no explicit avatar payload value and existing OAuth2 mapping
e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
// non-empty OAuth2 avatar url
e.OAuth2User.AvatarURL != "" {
@@ -285,7 +306,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
// set random password for users with unverified email
// (this is in case a malicious actor has registered previously with the user email)
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
e.Record.SetPassword(security.RandomString(30))
e.Record.SetRandomPassword()
needUpdate = true
}
@@ -335,27 +356,19 @@ func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2R
Body: payload,
}
response, err := processInternalRequest(txApp, e.RequestEvent, ir, core.RequestInfoContextOAuth2, nil)
var createdRecord *core.Record
response, err := processInternalRequest(txApp, e.RequestEvent, ir, core.RequestInfoContextOAuth2, func(data any) error {
createdRecord, _ = data.(*core.Record)
return nil
})
if err != nil {
return nil, err
}
if response.Status != http.StatusOK {
if response.Status != http.StatusOK || createdRecord == nil {
return nil, errors.New("failed to create OAuth2 auth record")
}
recordResponse := struct {
Id string `json:"id"`
}{}
raw, err := json.Marshal(response.Body)
if err != nil {
return nil, err
}
if err = json.Unmarshal(raw, &recordResponse); err != nil {
return nil, err
}
return txApp.FindRecordById(e.Collection, recordResponse.Id)
return createdRecord, nil
}
+36 -14
View File
@@ -56,18 +56,22 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
}
}
// note: don't exit because it is usually called as part of a separate goroutine
checkClientMessages := func(t testing.TB, clientId string, msg subscriptions.Message, expectedMessages map[string][]string) {
if len(expectedMessages[clientId]) == 0 {
t.Fatalf("Unexpected client %q message, got %s:\n%s", clientId, msg.Name, msg.Data)
t.Errorf("Unexpected client %q message, got %q:\n%q", clientId, msg.Name, msg.Data)
return
}
if msg.Name != "@oauth2" {
t.Fatalf("Expected @oauth2 msg.Name, got %q", msg.Name)
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
return
}
for _, txt := range expectedMessages[clientId] {
if !strings.Contains(string(msg.Data), txt) {
t.Fatalf("Failed to find %q in \n%s", txt, msg.Data)
t.Errorf("Failed to find %q in \n%s", txt, msg.Data)
return
}
}
}
@@ -91,19 +95,37 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
for {
select {
case msg := <-clients["c1"].Channel():
checkClientMessages(t, "c1", msg, expectedMessages)
case msg := <-clients["c2"].Channel():
checkClientMessages(t, "c2", msg, expectedMessages)
case msg := <-clients["c3"].Channel():
checkClientMessages(t, "c3", msg, expectedMessages)
case msg := <-clients["c4"].Channel():
checkClientMessages(t, "c4", msg, expectedMessages)
case msg := <-clients["c5"].Channel():
checkClientMessages(t, "c5", msg, expectedMessages)
case msg, ok := <-clients["c1"].Channel():
if ok {
checkClientMessages(t, "c1", msg, expectedMessages)
} else {
t.Errorf("Unexpected c1 closed channel")
}
case msg, ok := <-clients["c2"].Channel():
if ok {
checkClientMessages(t, "c2", msg, expectedMessages)
} else {
t.Errorf("Unexpected c2 closed channel")
}
case msg, ok := <-clients["c3"].Channel():
if ok {
checkClientMessages(t, "c3", msg, expectedMessages)
} else {
t.Errorf("Unexpected c3 closed channel")
}
case msg, ok := <-clients["c4"].Channel():
if ok {
checkClientMessages(t, "c4", msg, expectedMessages)
} else {
t.Errorf("Unexpected c4 closed channel")
}
case _, ok := <-clients["c5"].Channel():
if ok {
t.Errorf("Expected c5 channel to be closed")
}
case <-ctx.Done():
for _, c := range clients {
close(c.Channel())
c.Discard()
}
return
}
+218 -12
View File
@@ -14,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils"
"golang.org/x/oauth2"
)
@@ -174,6 +175,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if err := app.Save(ea); err != nil {
t.Fatal(err)
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
if err != nil {
t.Fatal(err)
}
if info.Context != core.RequestInfoContextOAuth2 {
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -292,6 +307,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test2@example.com"`,
`"id":"oap640cot4yru2s"`,
`"id":"test_id"`,
@@ -383,6 +399,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -488,6 +505,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -558,12 +576,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
oldTokenKey := user.TokenKey()
// manually unset the user email
user.SetEmail("")
if err := app.Save(user); err != nil {
if err = app.Save(user); err != nil {
t.Fatal(err)
}
// resave with the old token key since the email change above
// would change it and will make the password token invalid
user.SetTokenKey(oldTokenKey)
if err = app.Save(user); err != nil {
t.Fatalf("Failed to restore original user tokenKey: %v", err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
@@ -592,6 +619,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test_oauth2@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -697,6 +725,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test@example.com"`,
`"id":"4q1xlclmfloku33"`,
`"id":"test_id"`,
@@ -786,6 +815,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":true`,
`"email":""`,
`"id":"test_id"`,
`"verified":true`,
@@ -867,7 +897,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"verified":{"code":"validation_values_mismatch"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
`"email":`, // ignored because the record validator never ran
`"rel":`, // ignored because the record validator never ran
`"file":`, // ignored because the record validator never ran
},
@@ -920,12 +950,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{"code":"validation_is_email"`,
`"rel":{"code":"validation_missing_rel_records"`,
`"file":{"code":"validation_invalid_file"`,
},
NotExpectedContent: []string{
`"email":`, // the value is always overwritten with the OAuth2 user email
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
@@ -939,7 +967,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (valid create data)",
Name: "creating user (valid create data with empty submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -947,8 +975,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "invalid",
"email": "",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
"name": "test_name",
"username": "test_username",
"rel": "0yxhwia2amd8gec"
@@ -982,6 +1012,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":""`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1018,6 +1049,105 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnModelValidate": 4,
"OnRecordValidate": 4,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindFirstRecordByData("users", "username", "test_username")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
},
},
{
Name: "creating user (valid create data with non-empty valid submitted email)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"email": "test_create@example.com",
"emailVisibility": true,
"password": "1234567890",
"passwordConfirm": "1234567890",
"name": "test_name",
"username": "test_username",
"rel": "0yxhwia2amd8gec"
}
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
usersCol, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{
Id: "test_id",
Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(usersCol); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test_create@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
`"username":"test_username"`,
`"verified":false`,
`"rel":"0yxhwia2amd8gec"`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordCreateRequest": 1,
"OnRecordEnrich": 2, // the auth response and from the create request
// ---
"OnModelCreate": 3, // record + authOrigins + externalAuths
"OnModelCreateExecute": 3,
"OnModelAfterCreateSuccess": 3,
"OnRecordCreate": 3,
"OnRecordCreateExecute": 3,
"OnRecordAfterCreateSuccess": 3,
// ---
"OnModelValidate": 3,
"OnRecordValidate": 3,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindFirstRecordByData("users", "username", "test_username")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
},
},
{
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
@@ -1071,6 +1201,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
@@ -1153,6 +1284,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1189,7 +1321,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (with mapped OAuth2 fields and avatarURL->non-file field)",
Name: "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -1209,7 +1341,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
AuthUser: &auth.AuthUser{
Id: "oauth2_id",
Email: "oauth2@example.com",
Username: "oauth2_username",
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
Name: "oauth2_name",
AvatarURL: server.URL + "/oauth2_avatar.png",
},
@@ -1235,9 +1367,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"username":"oauth2_username"`,
`"username":"tESt2_username"`,
`"name":"http://127.`,
`"verified":true`,
`"avatar":""`,
@@ -1273,7 +1406,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "creating user (with mapped OAuth2 fields and duplicated username)",
Name: "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -1293,13 +1426,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
AuthUser: &auth.AuthUser{
Id: "oauth2_id",
Email: "oauth2@example.com",
Username: "test2_username",
Username: "tESt2_username",
Name: "oauth2_name",
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// make the username index case-insensitive to ensure that case-insensitive match is used
index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
if ok {
index.Columns[0].Collate = "nocase"
usersCol.RemoveIndex(index.IndexName)
usersCol.Indexes = append(usersCol.Indexes, index.Build())
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
@@ -1317,6 +1458,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1398,6 +1540,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":false`,
`"verified":true`,
@@ -1434,6 +1577,69 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordValidate": 4,
},
},
{
Name: "OnRecordAuthWithOAuth2Request tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
user.Collection().MFA.Enabled = false
user.Collection().OAuth2.Enabled = true
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
// stub linked provider
ea := core.NewExternalAuth(app)
ea.SetCollectionRef(user.Collection().Id)
ea.SetRecordRef(user.Id)
ea.SetProvider("test")
ea.SetProviderId("test_id")
if err := app.Save(ea); err != nil {
t.Fatal(err)
}
app.OnRecordAuthWithOAuth2Request().BindFunc(func(e *core.RecordAuthWithOAuth2RequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordAuthWithOAuth2Request": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+22 -15
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
@@ -51,8 +53,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
}
// since otps are usually simple digit numbers we enforce an extra rate limit rule to prevent enumerations
err = checkRateLimit(e, "@pb_otp_"+event.OTP.Id+event.Record.Id, core.RateLimitRule{MaxRequests: 4, Duration: 180})
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
if err != nil {
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
}
@@ -63,24 +65,29 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
// ---
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
if err != nil {
return err
}
// try to delete the used otp
if e.OTP != nil {
err = e.App.Delete(e.OTP)
// update the user email verified state in case the OTP originate from an email address matching the current record one
//
// note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
otpSentTo := e.OTP.SentTo()
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
e.Record.SetVerified(true)
err = e.App.Save(e.Record)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
"error", err,
"otpId", e.OTP.Id,
"recordId", e.Record.Id,
)
}
}
// note: we don't update the user verified state the same way as in the password reset confirmation
// at the moment because it is not clear whether the otp confirmation came from the user email
// (e.g. it could be from an sms or some other channel)
// try to delete the used otp
err = e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
return nil
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
})
}
+177 -7
View File
@@ -190,7 +190,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "valid otp with valid password",
Name: "valid otp with valid password (enabled MFA)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
@@ -236,7 +236,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
},
},
{
Name: "valid otp with valid password (disabled MFA)",
Name: "valid otp with valid password and empty sentTo (disabled MFA)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
@@ -249,8 +249,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
t.Fatal(err)
}
// ensure that the user is unverified
user.SetVerified(false)
if err = app.Save(user); err != nil {
t.Fatal(err)
}
// disable MFA
user.Collection().MFA.Enabled = false
if err := app.Save(user.Collection()); err != nil {
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
@@ -262,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{
@@ -297,6 +318,153 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected the user to remain unverified because sentTo != email")
}
},
},
{
Name: "valid otp with valid password and nonempty sentTo=email (disabled MFA)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
"otpId":"` + strings.Repeat("a", 15) + `",
"password":"123456"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// ensure that the user is unverified
user.SetVerified(false)
if err = app.Save(user); err != nil {
t.Fatal(err)
}
// disable MFA
user.Collection().MFA.Enabled = false
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
otp := core.NewOTP(app)
otp.Id = strings.Repeat("a", 15)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
otp.SetSentTo(user.Email())
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
`"record":{`,
`"email":"test@example.com"`,
},
NotExpectedContent: []string{
`"meta":`,
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOTPRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelValidate": 2, // +1 because of the verified user update
// authOrigin create
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
// OTP delete
"OnModelDelete": 1,
"OnModelDeleteExecute": 1,
"OnModelAfterDeleteSuccess": 1,
// user verified update
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
// ---
"OnRecordValidate": 2,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordDelete": 1,
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatal("Expected the user to be marked as verified")
}
},
},
{
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
@@ -387,11 +555,13 @@ func TestRecordAuthWithOTPManualRateLimiterCheck(t *testing.T) {
}{
{otpAId, "12345", 400},
{otpAId, "12345", 400},
{otpAId, "12345", 400},
{otpAId, "12345", 400},
{otpAId, "123456", 429},
{otpBId, "12345", 400},
{otpBId, "123456", 200},
{otpBId, "12345", 400},
{otpBId, "12345", 400},
{otpAId, "12345", 429},
{otpAId, "123456", 429}, // reject even if it is correct
{otpAId, "123456", 429},
{otpBId, "123456", 429},
}
for _, s := range scenarios {
+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
// -----------------------------------------------------------
{
+239 -96
View File
@@ -1,17 +1,23 @@
package apis
import (
cryptoRand "crypto/rand"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
)
// bindRecordCrudApi registers the record crud api endpoints and
@@ -22,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 {
@@ -53,23 +59,32 @@ func recordsList(e *core.RequestEvent) error {
return err
}
fieldsResolver := core.NewRecordFieldResolver(
e.App,
collection,
requestInfo,
// hidden fields are searchable only by superusers
requestInfo.HasSuperuserAuth(),
)
query := e.App.RecordQuery(collection)
searchProvider := search.NewProvider(fieldsResolver).
Query(e.App.RecordQuery(collection))
fieldsResolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil && *collection.ListRule != "" {
expr, err := search.FilterData(*collection.ListRule).BuildExpr(fieldsResolver)
if err != nil {
return err
}
query.AndWhere(expr)
// will be applied by the search provider right before executing the query
// fieldsResolver.UpdateQuery(query)
}
// hidden fields are searchable only by superusers
fieldsResolver.SetAllowHiddenFields(requestInfo.HasSuperuserAuth())
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 {
return firstApiError(err, e.BadRequestError("", err))
@@ -86,10 +101,46 @@ func recordsList(e *core.RequestEvent) error {
return firstApiError(err, e.InternalServerError("Failed to enrich records", err))
}
return e.JSON(http.StatusOK, e.Result)
// Add a randomized throttle in case of too many empty search filter attempts.
//
// This is just for extra precaution since security researches raised concern regarding the possibility of eventual
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
// This is by design and it is an accepted trade off between performance, usability and correctness.
//
// While technically the below doesn't fully guarantee protection against filter timing attacks, in practice combined with the network latency it makes them even less feasible.
// A properly configured rate limiter or individual fields Hidden checks are better suited if you are really concerned about eventual information disclosure by side-channel attacks.
//
// In all cases it doesn't really matter that much because it doesn't affect the builtin PocketBase security sensitive fields (e.g. password and tokenKey) since they
// are not client-side filterable and in the few places where they need to be compared against an external value, a constant time check is used.
if !e.HasSuperuserAuth() &&
(collection.ListRule != nil && *collection.ListRule != "") &&
(requestInfo.Query["filter"] != "") &&
len(e.Records) == 0 &&
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
randomizedThrottle(150)
}
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Result)
})
})
}
var listTimingRateLimitRule = core.RateLimitRule{MaxRequests: 3, Duration: 3}
func randomizedThrottle(softMax int64) {
var timeout int64
randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(softMax))
if err == nil {
timeout = randRange.Int64()
} else {
timeout = softMax
}
time.Sleep(time.Duration(timeout) * time.Millisecond)
}
func recordView(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -143,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() 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 {
@@ -169,17 +222,7 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
}
hasSuperuserAuth := requestInfo.HasSuperuserAuth()
canSkipRuleCheck := hasSuperuserAuth
// special case for the first superuser creation
// ---
if !canSkipRuleCheck && collection.Name == core.CollectionNameSuperusers {
total, totalErr := e.App.CountRecords(core.CollectionNameSuperusers)
canSkipRuleCheck = totalErr == nil && total == 0
}
// ---
if !canSkipRuleCheck && collection.CreateRule == nil {
if !hasSuperuserAuth && collection.CreateRule == nil {
return e.ForbiddenError("Only superusers can perform this action.", nil)
}
@@ -190,6 +233,16 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
}
// set a random password for the OAuth2 ignoring its plain password validators
var skipPlainPasswordRecordValidators bool
if requestInfo.Context == core.RequestInfoContextOAuth2 {
if _, ok := data[core.FieldNamePassword]; !ok {
data[core.FieldNamePassword] = security.RandomString(30)
data[core.FieldNamePassword+"Confirm"] = data[core.FieldNamePassword]
skipPlainPasswordRecordValidators = true
}
}
// replace modifiers fields so that the resolved value is always
// available when accessing requestInfo.Body
requestInfo.Body = data
@@ -200,6 +253,80 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
}
form.Load(data)
if skipPlainPasswordRecordValidators {
// unset the plain value to skip the plain password field validators
if raw, ok := record.GetRaw(core.FieldNamePassword).(*core.PasswordFieldValue); ok {
raw.Plain = ""
}
}
// 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)
@@ -211,59 +338,9 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
form.SetApp(e.App)
form.SetRecord(e.Record)
// temporary save the record and check it against the create and manage rules
if !canSkipRuleCheck && e.Collection.CreateRule != nil {
// temporary grant manager access level
form.GrantManagerAccess()
// manually unset the verified field to prevent manage API rule misuse in case the rule relies on it
initialVerified := e.Record.Verified()
if initialVerified {
e.Record.SetVerified(false)
}
createRuleFunc := func(q *dbx.SelectQuery) error {
if *e.Collection.CreateRule == "" {
return nil // no create rule to resolve
}
resolver := core.NewRecordFieldResolver(e.App, e.Collection, requestInfo, true)
expr, err := search.FilterData(*e.Collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
testErr := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
foundRecord, err := txApp.FindRecordById(drySavedRecord.Collection(), drySavedRecord.Id, createRuleFunc)
if err != nil {
return fmt.Errorf("DrySubmit create rule failure: %w", err)
}
// reset the form access level in case it satisfies the Manage API rule
if !hasAuthManageAccess(txApp, requestInfo, foundRecord) {
form.ResetAccess()
}
return nil
})
if testErr != nil {
return e.BadRequestError("Failed to create record.", testErr)
}
// restore initial verified state (it will be further validated on submit)
if initialVerified != e.Record.Verified() {
e.Record.SetVerified(initialVerified)
}
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to create record.", err))
return firstApiError(err, e.BadRequestError("Failed to create record", err))
}
err = EnrichRecord(e.RequestEvent, e.Record)
@@ -271,14 +348,16 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
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
}
if optFinalizer != nil {
isOptFinalizerCalled = true
err = optFinalizer()
err = optFinalizer(e.Record)
if err != nil {
return firstApiError(err, e.InternalServerError("", err))
}
@@ -292,7 +371,7 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
// e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
if !isOptFinalizerCalled && optFinalizer != nil {
if err := optFinalizer(); err != nil {
if err := optFinalizer(event.Record); err != nil {
return firstApiError(err, e.InternalServerError("", err))
}
}
@@ -301,7 +380,7 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
}
}
func recordUpdate(optFinalizer func() 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 {
@@ -373,6 +452,14 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
}
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)
@@ -383,9 +470,6 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
form.SetApp(e.App)
form.SetRecord(e.Record)
if !form.HasManageAccess() && hasAuthManageAccess(e.App, requestInfo, e.Record) {
form.GrantManagerAccess()
}
err := form.Submit()
if err != nil {
@@ -397,14 +481,16 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
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
}
if optFinalizer != nil {
isOptFinalizerCalled = true
err = optFinalizer()
err = optFinalizer(e.Record)
if err != nil {
return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err)))
}
@@ -418,7 +504,7 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
// e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
if !isOptFinalizerCalled && optFinalizer != nil {
if err := optFinalizer(); err != nil {
if err := optFinalizer(event.Record); err != nil {
return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err)))
}
}
@@ -427,7 +513,7 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
}
}
func recordDelete(optFinalizer func() 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 {
@@ -487,14 +573,16 @@ func recordDelete(optFinalizer func() error) func(e *core.RequestEvent) error {
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
}
if optFinalizer != nil {
isOptFinalizerCalled = true
err = optFinalizer()
err = optFinalizer(e.Record)
if err != nil {
return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err)))
}
@@ -508,7 +596,7 @@ func recordDelete(optFinalizer func() error) func(e *core.RequestEvent) error {
// e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
if !isOptFinalizerCalled && optFinalizer != nil {
if err := optFinalizer(); err != nil {
if err := optFinalizer(event.Record); err != nil {
return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err)))
}
}
@@ -534,9 +622,28 @@ func recordDataFromRequest(e *core.RequestEvent, record *core.Record) (map[strin
return nil, err
}
if len(uploadedFiles) > 0 {
for k, v := range uploadedFiles {
result[k] = v
for k, files := range uploadedFiles {
uploaded := make([]any, 0, len(files))
// if not remove/prepend/append -> merge with the submitted
// info.Body values to prevent accidental old files deletion
if info.Body[k] != nil &&
!strings.HasPrefix(k, "+") &&
!strings.HasSuffix(k, "+") &&
!strings.HasSuffix(k, "-") {
existing := list.ToUniqueStringSlice(info.Body[k])
for _, name := range existing {
uploaded = append(uploaded, name)
}
}
for _, file := range files {
uploaded = append(uploaded, file)
}
result[k] = uploaded
}
result = record.ReplaceModifiers(result)
}
@@ -597,3 +704,39 @@ func extractUploadedFiles(re *core.RequestEvent, collection *core.Collection, pr
return result, nil
}
// hasAuthManageAccess checks whether the client is allowed to have
// [forms.RecordUpsert] auth management permissions
// (e.g. allowing to change system auth fields without oldPassword).
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection *core.Collection, query *dbx.SelectQuery) bool {
if !collection.IsAuth() {
return false
}
manageRule := collection.ManageRule
if manageRule == nil || *manageRule == "" {
return false // only for superusers (manageRule can't be empty)
}
if requestInfo == nil || requestInfo.Auth == nil {
return false // no auth record
}
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
app.Logger().Error("Manage rule build expression error", "error", err, "collectionId", collection.Id)
return false
}
query.AndWhere(expr)
resolver.UpdateQuery(query)
var exists int
err = query.Limit(1).Row(&exists)
return err == nil && exists > 0
}
+2 -2
View File
@@ -218,7 +218,7 @@ func TestRecordCrudAuthOriginCreate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
ExpectedContent: []string{
@@ -284,7 +284,7 @@ func TestRecordCrudAuthOriginUpdate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
ExpectedContent: []string{
+2 -2
View File
@@ -219,7 +219,7 @@ func TestRecordCrudExternalAuthCreate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
ExpectedContent: []string{
@@ -286,7 +286,7 @@ func TestRecordCrudExternalAuthUpdate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
ExpectedContent: []string{
+21 -4
View File
@@ -170,7 +170,7 @@ func TestRecordCrudMFADelete(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 404,
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
@@ -187,7 +187,7 @@ func TestRecordCrudMFADelete(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 404,
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
@@ -204,6 +204,23 @@ func TestRecordCrudMFADelete(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superusers auth",
Method: http.MethodDelete,
URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubMFARecords(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"*": 0,
@@ -273,7 +290,7 @@ func TestRecordCrudMFACreate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
@@ -354,7 +371,7 @@ func TestRecordCrudMFAUpdate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+23 -6
View File
@@ -170,12 +170,12 @@ func TestRecordCrudOTPDelete(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 404,
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "non-owner",
Name: "non-owner auth",
Method: http.MethodDelete,
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
Headers: map[string]string{
@@ -187,12 +187,12 @@ func TestRecordCrudOTPDelete(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 404,
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "owner",
Name: "owner regular auth",
Method: http.MethodDelete,
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
Headers: map[string]string{
@@ -204,6 +204,23 @@ func TestRecordCrudOTPDelete(t *testing.T) {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superusers auth",
Method: http.MethodDelete,
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubOTPRecords(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"*": 0,
@@ -273,7 +290,7 @@ func TestRecordCrudOTPCreate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
@@ -354,7 +371,7 @@ func TestRecordCrudOTPUpdate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+6 -41
View File
@@ -40,7 +40,7 @@ func TestRecordCrudSuperuserList(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
Headers: map[string]string{
// _superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -93,7 +93,7 @@ func TestRecordCrudSuperuserView(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
Headers: map[string]string{
// _superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -142,7 +142,7 @@ func TestRecordCrudSuperuserDelete(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sbmbsdb40jyxf7h",
Headers: map[string]string{
// _superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -162,7 +162,7 @@ func TestRecordCrudSuperuserDelete(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
Headers: map[string]string{
// _superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all other superusers
@@ -229,48 +229,13 @@ func TestRecordCrudSuperuserCreate(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "guest creating first superuser",
Method: http.MethodPost,
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
Body: body(),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// delete all superusers
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
if err != nil {
t.Fatal(err)
}
},
ExpectedContent: []string{
`"collectionName":"_superusers"`,
`"verified":true`,
},
NotExpectedContent: []string{
// because the action has no auth the email field shouldn't be returned if emailVisibility is not set
`"email"`,
},
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
"OnRecordEnrich": 1,
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
},
},
{
Name: "superusers auth",
Method: http.MethodPost,
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
Headers: map[string]string{
// _superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
ExpectedContent: []string{
@@ -339,7 +304,7 @@ func TestRecordCrudSuperuserUpdate(t *testing.T) {
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
Headers: map[string]string{
// _superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Body: body(),
ExpectedContent: []string{
+254 -79
View File
@@ -2,7 +2,6 @@ package apis_test
import (
"bytes"
"errors"
"net/http"
"net/url"
"os"
@@ -121,7 +120,7 @@ func TestRecordCrudList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/demo1/records",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -145,7 +144,7 @@ func TestRecordCrudList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/demo1/records?filter=text~'test'&sort=-bool",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -167,7 +166,7 @@ func TestRecordCrudList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/demo1/records?filter=invalid~'test'",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -178,7 +177,7 @@ func TestRecordCrudList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/demo1/records?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -259,6 +258,110 @@ func TestRecordCrudList(t *testing.T) {
"OnRecordEnrich": 4,
},
},
{
Name: "authenticated regular record that matches the collection list rule with hidden field",
Method: http.MethodGet,
URL: "/api/collections/demo3/records",
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// mock hidden field
col.Fields.GetByName("title").SetHidden(true)
col.ListRule = types.Pointer("title ~ 'test'")
if err = app.Save(col); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":4`,
`"items":[{`,
`"id":"1tmknxy2868d869"`,
`"id":"lcl9d87w22ml6jy"`,
`"id":"7nwo8tuiatetxdm"`,
`"id":"mk5fmymtx4wsprk"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 4,
},
},
{
Name: "authenticated regular record filtering with a hidden field",
Method: http.MethodGet,
URL: "/api/collections/demo3/records?filter=title~'test'",
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// mock hidden field
col.Fields.GetByName("title").SetHidden(true)
if err = app.Save(col); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser filtering with a hidden field",
Method: http.MethodGet,
URL: "/api/collections/demo3/records?filter=title~'test'",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// mock hidden field
col.Fields.GetByName("title").SetHidden(true)
if err = app.Save(col); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":4`,
`"items":[{`,
`"id":"1tmknxy2868d869"`,
`"id":"lcl9d87w22ml6jy"`,
`"id":"7nwo8tuiatetxdm"`,
`"id":"mk5fmymtx4wsprk"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 4,
},
},
{
Name: ":rule modifer",
Method: http.MethodGet,
@@ -314,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
// -----------------------------------------------------------
@@ -420,7 +549,7 @@ func TestRecordCrudList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/nologin/records",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -698,7 +827,7 @@ func TestRecordCrudView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/demo1/records/imy661ixudk5izi",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -738,7 +867,7 @@ func TestRecordCrudView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/demo1/records/al1h9ijdeojtsjy?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -758,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
// -----------------------------------------------------------
@@ -833,7 +988,7 @@ func TestRecordCrudView(t *testing.T) {
Method: http.MethodGet,
URL: "/api/collections/nologin/records/oos036e9xvqeexy",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1090,7 +1245,7 @@ func TestRecordCrudDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/clients/records/o1y0dd0spd786md",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -1105,23 +1260,30 @@ 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{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
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",
@@ -1197,7 +1359,7 @@ func TestRecordCrudDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/demo3/records/7nwo8tuiatetxdm",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -1223,7 +1385,7 @@ func TestRecordCrudDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/demo3/records/1tmknxy2868d869",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -1248,7 +1410,7 @@ func TestRecordCrudDelete(t *testing.T) {
Method: http.MethodDelete,
URL: "/api/collections/users/records/oap640cot4yru2s",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
@@ -1459,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)",
@@ -1475,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",
@@ -1544,7 +1700,7 @@ func TestRecordCrudCreate(t *testing.T) {
"rel_many_cascade":"lcl9d87w22ml6jy"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1585,7 +1741,7 @@ func TestRecordCrudCreate(t *testing.T) {
Body: formData,
Headers: map[string]string{
"Content-Type": mp.FormDataContentType(),
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1627,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",
@@ -1684,29 +1837,44 @@ func TestRecordCrudCreate(t *testing.T) {
`"code":"validation_not_unique"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
// validate events are not fired because the unique check will fail during dry submit
// "OnModelValidate": 1,
// "OnRecordValidate": 1,
"*": 0,
"OnRecordCreateRequest": 1,
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateError": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateError": 1,
"OnRecordValidate": 1,
},
},
{
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
@@ -1720,7 +1888,7 @@ func TestRecordCrudCreate(t *testing.T) {
"title": "test"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1746,7 +1914,7 @@ func TestRecordCrudCreate(t *testing.T) {
"title": "test"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1772,7 +1940,7 @@ func TestRecordCrudCreate(t *testing.T) {
"title": "test"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1802,7 +1970,7 @@ func TestRecordCrudCreate(t *testing.T) {
"title": "test"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1834,7 +2002,7 @@ func TestRecordCrudCreate(t *testing.T) {
"passwordConfirm":"1234567890"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1865,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)",
@@ -1916,7 +2079,7 @@ func TestRecordCrudCreate(t *testing.T) {
"username":"Users75657"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -1943,7 +2106,7 @@ func TestRecordCrudCreate(t *testing.T) {
"rel":"invalid"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -2025,7 +2188,7 @@ func TestRecordCrudCreate(t *testing.T) {
"verified":true
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -2169,7 +2332,7 @@ func TestRecordCrudCreate(t *testing.T) {
}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
@@ -2345,7 +2508,7 @@ func TestRecordCrudUpdate(t *testing.T) {
}
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123}`,
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123, "files":"300_JdfBOieXAW.png"}`,
}, "files")
if err3 != nil {
t.Fatal(err3)
@@ -2578,7 +2741,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"rel_many_cascade":"lcl9d87w22ml6jy"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -2619,7 +2782,7 @@ func TestRecordCrudUpdate(t *testing.T) {
Body: formData,
Headers: map[string]string{
"Content-Type": mp.FormDataContentType(),
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -2686,6 +2849,8 @@ func TestRecordCrudUpdate(t *testing.T) {
`"id":"mk5fmymtx4wsprk"`,
`"title":"title_test3"`,
`"files":["`,
`"300_JdfBOieXAW.png"`,
`"tmpfile_`,
},
ExpectedEvents: map[string]int{
"*": 0,
@@ -2702,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",
@@ -2726,7 +2901,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"id": "mk5fmymtx4wspra"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -2829,7 +3004,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"verified":false
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -2857,7 +3032,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"rel":"invalid"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -3013,7 +3188,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"verified":false
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -3251,7 +3426,7 @@ func TestRecordCrudUpdate(t *testing.T) {
}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
col, err := app.FindCollectionByNameOrId("demo3")
+78 -58
View File
@@ -6,11 +6,13 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -20,6 +22,8 @@ const (
fieldsQueryParam = "fields"
)
var ErrMFA = errors.New("mfa required")
// RecordAuthResponse writes standardized json record auth response
// into the specified request context.
//
@@ -70,9 +74,12 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
// require additional authentication
if mfaId != "" {
return e.JSON(http.StatusUnauthorized, map[string]string{
// eagerly write the mfa response and return an err so that
// external middlewars are aware that the auth response requires an extra step
e.JSON(http.StatusUnauthorized, map[string]string{
"mfaId": mfaId,
})
return ErrMFA
}
// ---
@@ -109,20 +116,27 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
}
}
result := map[string]any{
"token": e.Token,
"record": e.Record,
result := struct {
Meta any `json:"meta,omitempty"`
Record *core.Record `json:"record"`
Token string `json:"token"`
}{
Token: e.Token,
Record: e.Record,
}
if e.Meta != nil {
result["meta"] = e.Meta
result.Meta = e.Meta
}
return e.JSON(http.StatusOK, result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, result)
})
})
}
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule.
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule
// (note: returns true even in case of an error as a safer default).
func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
rule := record.Collection().MFA.Rule
if rule == "" {
@@ -131,10 +145,10 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
requestInfo, err := e.RequestInfo()
if err != nil {
return false, err
return true, err
}
var exists bool
var exists int
query := e.App.RecordQuery(record.Collection()).
Select("(1)").
@@ -144,16 +158,16 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
expr, err := search.FilterData(rule).BuildExpr(resolver)
if err != nil {
return false, err
return true, err
}
resolver.UpdateQuery(query)
err = query.AndWhere(expr).Limit(1).Row(&exists)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
return true, err
}
return exists, nil
return exists > 0, nil
}
// checkMFA handles any MFA auth checks that needs to be performed for the specified request event.
@@ -166,11 +180,10 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
}
ok, err := wantsMFA(e, authRecord)
if err != nil {
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
}
if !ok {
if err != nil {
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
}
return "", nil // no mfa needed for this auth record
}
@@ -214,7 +227,7 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
}
if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
deleteMFA()
return "", firstApiError(err, e.BadRequestError("Invalid or expired MFA session.", err))
return "", e.BadRequestError("Invalid or expired MFA session.", err)
}
if mfa.RecordRef() != authRecord.Id || mfa.CollectionRef() != authRecord.Collection().Id {
@@ -256,13 +269,13 @@ func EnrichRecords(e *core.RequestEvent, records []*core.Record, defaultExpands
return triggerRecordEnrichHooks(e.App, info, records, func() error {
expands := defaultExpands
if param := e.Request.URL.Query().Get(expandQueryParam); param != "" {
if param := info.Query[expandQueryParam]; param != "" {
expands = append(expands, strings.Split(param, ",")...)
}
err := defaultEnrichRecords(e.App, info, records, expands...)
if err != nil {
// only log as it is not critical
// only log because it is not critical
e.App.Logger().Warn("failed to apply default enriching", "error", err)
}
@@ -270,8 +283,6 @@ func EnrichRecords(e *core.RequestEvent, records []*core.Record, defaultExpands
})
}
var iterate func(record *core.Record) error
type iterator[T any] struct {
items []T
index int
@@ -297,6 +308,7 @@ func triggerRecordEnrichHooks(app core.App, requestInfo *core.RequestInfo, recor
event.App = app
event.RequestInfo = requestInfo
var iterate func(record *core.Record) error
iterate = func(record *core.Record) error {
if record == nil {
return nil
@@ -350,6 +362,7 @@ func defaultEnrichRecords(app core.App, requestInfo *core.RequestInfo, records [
// expandFetch is the records fetch function that is used to expand related records.
func expandFetch(app core.App, originalRequestInfo *core.RequestInfo) core.ExpandFetchFunc {
// shallow clone the provided request info to set an "expand" context
requestInfoClone := *originalRequestInfo
requestInfoPtr := &requestInfoClone
requestInfoPtr.Context = core.RequestInfoContextExpand
@@ -444,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)
@@ -470,40 +483,6 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
return nil
}
// hasAuthManageAccess checks whether the client is allowed to have
// [forms.RecordUpsert] auth management permissions
// (e.g. allowing to change system auth fields without oldPassword).
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, record *core.Record) bool {
if !record.Collection().IsAuth() {
return false
}
manageRule := record.Collection().ManageRule
if manageRule == nil || *manageRule == "" {
return false // only for superusers (manageRule can't be empty)
}
if requestInfo == nil || requestInfo.Auth == nil {
return false // no auth record
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := core.NewRecordFieldResolver(app, record.Collection(), requestInfo, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
_, findErr := app.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
return findErr == nil
}
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
var superuserOnlyRuleFields = []string{"@collection.", "@request."}
@@ -558,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
@@ -594,8 +594,28 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
}
// send email alert for the new origin auth (skip first login)
//
// Note: The "fake" timeout is a temp solution to avoid blocking
// for too long when the SMTP server is not accessible, due
// to the lack of context cancellation support in the underlying
// mailer and net/smtp package.
// The goroutine technically "leaks" but we assume that the OS will
// terminate the connection after some time (usually after 3-4 mins).
if !isFirstLogin && currentOrigin.IsNew() && authRecord.Email() != "" {
if err := mails.SendRecordAuthAlert(e.App, authRecord); err != nil {
mailSent := make(chan error, 1)
timer := time.AfterFunc(15*time.Second, func() {
mailSent <- errors.New("auth alert mail send wait timeout reached")
})
routine.FireAndForget(func() {
err := mails.SendRecordAuthAlert(e.App, authRecord)
timer.Stop()
mailSent <- err
})
err = <-mailSent
if err != nil {
return err
}
}
+23 -13
View File
@@ -2,6 +2,7 @@ package apis_test
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -23,6 +24,14 @@ func TestEnrichRecords(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
freshRecords := func(records []*core.Record) []*core.Record {
result := make([]*core.Record, len(records))
for i, r := range records {
result[i] = r.Fresh()
}
return result
}
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
@@ -77,7 +86,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[emailVisibility] guest",
auth: nil,
records: usersRecords,
records: freshRecords(usersRecords),
queryExpand: "",
defaultExpands: nil,
expected: []string{
@@ -91,7 +100,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[emailVisibility] owner",
auth: user,
records: usersRecords,
records: freshRecords(usersRecords),
queryExpand: "",
defaultExpands: nil,
expected: []string{
@@ -103,7 +112,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[emailVisibility] manager",
auth: user,
records: nologinRecords,
records: freshRecords(nologinRecords),
queryExpand: "",
defaultExpands: nil,
expected: []string{
@@ -115,7 +124,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[emailVisibility] superuser",
auth: superuser,
records: nologinRecords,
records: freshRecords(nologinRecords),
queryExpand: "",
defaultExpands: nil,
expected: []string{
@@ -127,7 +136,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[emailVisibility + expand] recursive auth rule checks (regular user)",
auth: user,
records: demo1Records,
records: freshRecords(demo1Records),
queryExpand: "",
defaultExpands: []string{"rel_many"},
expected: []string{
@@ -144,7 +153,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[emailVisibility + expand] recursive auth rule checks (superuser)",
auth: superuser,
records: demo1Records,
records: freshRecords(demo1Records),
queryExpand: "",
defaultExpands: []string{"rel_many"},
expected: []string{
@@ -152,6 +161,7 @@ func TestEnrichRecords(t *testing.T) {
`"test@example.com"`,
`"expand":{"rel_many"`,
`"id":"bgs820n361vj1qd"`,
`"id":"4q1xlclmfloku33"`,
`"id":"oap640cot4yru2s"`,
},
notExpected: []string{
@@ -163,7 +173,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[expand] guest (query)",
auth: nil,
records: usersRecords,
records: freshRecords(usersRecords),
queryExpand: "rel",
defaultExpands: nil,
expected: []string{
@@ -179,7 +189,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[expand] guest (default expands)",
auth: nil,
records: usersRecords,
records: freshRecords(usersRecords),
queryExpand: "",
defaultExpands: []string{"rel"},
expected: []string{
@@ -192,7 +202,7 @@ func TestEnrichRecords(t *testing.T) {
{
name: "[expand] @request.context=expand check",
auth: nil,
records: demo5Records,
records: freshRecords(demo5Records),
queryExpand: "rel_one",
defaultExpands: []string{"rel_many"},
expected: []string{
@@ -575,8 +585,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
user.Collection().MFA.Rule = "1=1"
err = apis.RecordAuthResponse(event, user, "example", nil)
if err != nil {
t.Fatalf("Expected nil, got error: %v", err)
if !errors.Is(err, apis.ErrMFA) {
t.Fatalf("Expected ErrMFA, got: %v", err)
}
body := rec.Body.String()
@@ -593,8 +603,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
resetMFAs(user)
err = apis.RecordAuthResponse(event, user, "example", nil)
if err != nil {
t.Fatalf("Expected nil, got error: %v", err)
if !errors.Is(err, apis.ErrMFA) {
t.Fatalf("Expected ErrMFA, got: %v", err)
}
body := rec.Body.String()
+65 -41
View File
@@ -16,6 +16,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/ui"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
@@ -26,12 +27,6 @@ type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
ShowStartBanner bool
// DashboardPath specifies the route path to the superusers dashboard interface
// (default to "/_/{path...}").
//
// Note: Must include the "{path...}" wildcard parameter.
DashboardPath string
// HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
HttpAddr string
@@ -66,12 +61,6 @@ func Serve(app core.App, config ServeConfig) error {
config.AllowedOrigins = []string{"*"}
}
if config.DashboardPath == "" {
config.DashboardPath = "/_/{path...}"
} else if !strings.HasSuffix(config.DashboardPath, "{path...}") {
return errors.New("invalid dashboard path - missing {path...} wildcard")
}
// ensure that the latest migrations are applied before starting the server
err := app.RunAllMigrations()
if err != nil {
@@ -83,16 +72,25 @@ func Serve(app core.App, config ServeConfig) error {
return err
}
pbRouter.Bind(CORSWithConfig(CORSConfig{
pbRouter.Bind(CORS(CORSConfig{
AllowOrigins: config.AllowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
pbRouter.BindFunc(installerRedirect(app, config.DashboardPath))
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
BindFunc(func(e *core.RequestEvent) error {
// ignore root path
if e.Request.PathValue(StaticWildcardParam) != "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
pbRouter.GET(config.DashboardPath, Static(ui.DistDirFS, false)).
BindFunc(dashboardRemoveInstallerParam()).
BindFunc(dashboardCacheControl()).
// add a default CSP
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* 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()
}).
Bind(Gzip())
// start http server
@@ -145,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
@@ -160,6 +158,7 @@ func Serve(app core.App, config ServeConfig) error {
serveEvent.Router = pbRouter
serveEvent.Server = server
serveEvent.CertManager = certManager
serveEvent.InstallerFunc = DefaultInstallerFunc
var listener net.Listener
@@ -206,6 +205,8 @@ func Serve(app core.App, config ServeConfig) error {
}()
// ---------------------------------------------------------------
var baseURL string
// trigger the OnServe hook and start the tcp listener
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
handler, err := e.Router.BuildMux()
@@ -215,10 +216,20 @@ func Serve(app core.App, config ServeConfig) error {
e.Server.Handler = handler
addr := e.Server.Addr
if config.HttpsAddr == "" {
baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr)
} else {
baseURL = "https://"
if len(config.CertificateDomains) > 0 {
baseURL += config.CertificateDomains[0]
} else {
baseURL += serverAddrToHost(serveEvent.Server.Addr)
}
}
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
addr := e.Server.Addr
if addr == "" {
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
if config.HttpsAddr != "" {
addr = ":https"
} else {
@@ -226,28 +237,33 @@ func Serve(app core.App, config ServeConfig) error {
}
}
var lnErr error
listener, err = net.Listen("tcp", addr)
if err != nil {
return err
}
listener, lnErr = net.Listen("tcp", addr)
if e.InstallerFunc != nil {
app := e.App
installerFunc := e.InstallerFunc
routine.FireAndForget(func() {
if err := loadInstaller(app, baseURL, installerFunc); err != nil {
app.Logger().Warn("Failed to initialize installer", "error", err)
}
})
}
return lnErr
return nil
})
if serveHookErr != nil {
return serveHookErr
}
if listener == nil {
//nolint:staticcheck
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
}
if config.ShowStartBanner {
schema := "http"
addr := server.Addr
if config.HttpsAddr != "" {
schema = "https"
if len(config.CertificateDomains) > 0 {
addr = config.CertificateDomains[0]
}
}
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
@@ -255,12 +271,12 @@ func Serve(app core.App, config ServeConfig) error {
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, addr),
color.CyanString("%s", baseURL),
)
regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
}
var serveErr error
@@ -271,10 +287,10 @@ func Serve(app core.App, config ServeConfig) error {
}
// start HTTPS server
serveErr = server.ServeTLS(listener, "", "")
serveErr = serveEvent.Server.ServeTLS(listener, "", "")
} else {
// OR start HTTP server
serveErr = server.Serve(listener)
serveErr = serveEvent.Server.Serve(listener)
}
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
@@ -283,6 +299,14 @@ func Serve(app core.App, config ServeConfig) error {
return nil
}
// serverAddrToHost loosely converts http.Server.Addr string into a host to print.
func serverAddrToHost(addr string) string {
if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") {
return "127.0.0.1"
}
return addr
}
type serverErrorLogWriter struct {
app core.App
}
+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)
})
})
}
+71 -17
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
@@ -42,7 +43,7 @@ func TestSettingsList(t *testing.T) {
Method: http.MethodGet,
URL: "/api/settings",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -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 {
@@ -102,7 +129,7 @@ func TestSettingsSet(t *testing.T) {
URL: "/api/settings",
Body: strings.NewReader(``),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -129,7 +156,7 @@ func TestSettingsSet(t *testing.T) {
URL: "/api/settings",
Body: strings.NewReader(`{"meta":{"appName":""}}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -150,7 +177,7 @@ func TestSettingsSet(t *testing.T) {
URL: "/api/settings",
Body: strings.NewReader(validData),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -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 {
@@ -211,7 +265,7 @@ func TestSettingsTestS3(t *testing.T) {
Method: http.MethodPost,
URL: "/api/settings/test/s3",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -226,7 +280,7 @@ func TestSettingsTestS3(t *testing.T) {
URL: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"invalid"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -241,7 +295,7 @@ func TestSettingsTestS3(t *testing.T) {
URL: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"storage"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -293,7 +347,7 @@ func TestSettingsTestEmail(t *testing.T) {
URL: "/api/settings/test/email",
Body: strings.NewReader(`{`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -305,7 +359,7 @@ func TestSettingsTestEmail(t *testing.T) {
URL: "/api/settings/test/email",
Body: strings.NewReader(`{}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -323,7 +377,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
@@ -359,7 +413,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
@@ -395,7 +449,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
@@ -431,7 +485,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend() != 1 {
@@ -511,7 +565,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
URL: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -523,7 +577,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
URL: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -547,7 +601,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
"duration": -1
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -571,7 +625,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
"duration": 1
}`, privatePem)),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
-9
View File
@@ -15,7 +15,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
var allowedOrigins []string
var httpAddr string
var httpsAddr string
var dashboardPath string
command := &cobra.Command{
Use: "serve [domain(s)]",
@@ -40,7 +39,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
err := apis.Serve(app, apis.ServeConfig{
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
DashboardPath: dashboardPath,
ShowStartBanner: showStartBanner,
AllowedOrigins: allowedOrigins,
CertificateDomains: args,
@@ -75,12 +73,5 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
)
command.PersistentFlags().StringVar(
&dashboardPath,
"dashboard",
"/_/{path...}",
"The route path to the superusers dashboard; must include the '{path...}' wildcard parameter",
)
return command
}
+66 -21
View File
@@ -7,21 +7,23 @@ import (
"github.com/fatih/color"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cobra"
)
// NewSuperuserCommand creates and returns new command for managing
// superuser accounts (create, update, delete).
// superuser accounts (create, update, upsert, delete).
func NewSuperuserCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "superuser",
Short: "Manages superuser accounts",
Short: "Manage superusers",
}
command.AddCommand(superuserUpsertCommand(app))
command.AddCommand(superuserCreateCommand(app))
command.AddCommand(superuserUpdateCommand(app))
command.AddCommand(superuserDeleteCommand(app))
command.AddCommand(superuserOTPCommand(app))
return command
}
@@ -30,20 +32,20 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "upsert",
Example: "superuser upsert test@example.com 1234567890",
Short: "Creates, or updates if email exists, a single superuser account",
Short: "Creates, or updates if email exists, a single superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
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])
@@ -55,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())
@@ -70,20 +72,20 @@ func superuserCreateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "create",
Example: "superuser create test@example.com 1234567890",
Short: "Creates a new superuser account",
Short: "Creates a new superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
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)
@@ -91,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())
@@ -106,26 +108,26 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "update",
Example: "superuser update test@example.com 1234567890",
Short: "Changes the password of a single superuser account",
Short: "Changes the password of a single superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
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())
@@ -140,21 +142,21 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "delete",
Example: "superuser delete test@example.com",
Short: "Deletes an existing superuser account",
Short: "Deletes an existing superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
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())
@@ -164,3 +166,46 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
return command
}
func superuserOTPCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "otp",
Example: "superuser otp test@example.com",
Short: "Creates a new OTP for the specified superuser",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("invalid or missing email address")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
}
if !superuser.Collection().OTP.Enabled {
return errors.New("OTP auth is not enabled for the _superusers collection")
}
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
otp := core.NewOTP(app)
otp.SetCollectionRef(superuser.Collection().Id)
otp.SetRecordRef(superuser.Id)
otp.SetPassword(pass)
err = app.Save(otp)
if err != nil {
return fmt.Errorf("failed to create OTP: %w", err)
}
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
color.Green("\n├─ Id: %s", otp.Id)
color.Green("├─ Pass: %s", pass)
color.Green("└─ Valid: %ds\n\n", superuser.Collection().OTP.Duration)
return nil
},
}
return command
}
+93
View File
@@ -308,3 +308,96 @@ func TestSuperuserDeleteCommand(t *testing.T) {
})
}
}
func TestSuperuserOTPCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
t.Fatal(err)
}
// remove all existing otps
otps, err := app.FindAllOTPsByCollection(superusersCollection)
if err != nil {
t.Fatal(err)
}
for _, otp := range otps {
err = app.Delete(otp)
if err != nil {
t.Fatal(err)
}
}
scenarios := []struct {
name string
email string
enabled bool
expectError bool
}{
{
"empty email",
"",
true,
true,
},
{
"invalid email",
"invalid",
true,
true,
},
{
"nonexisting superuser",
"test_missing@example.com",
true,
true,
},
{
"existing superuser",
"test@example.com",
true,
false,
},
{
"existing superuser with disabled OTP",
"test@example.com",
false,
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
command := cmd.NewSuperuserCommand(app)
command.SetArgs([]string{"otp", s.email})
superusersCollection.OTP.Enabled = s.enabled
if err = app.SaveNoValidate(superusersCollection); err != nil {
t.Fatal(err)
}
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if hasErr {
return
}
superuser, err := app.FindAuthRecordByEmail(superusersCollection, s.email)
if err != nil {
t.Fatal(err)
}
otps, _ := app.FindAllOTPsByRecord(superuser)
if total := len(otps); total != 1 {
t.Fatalf("Expected 1 OTP, got %d", total)
}
})
}
}
+164 -94
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.).
//
@@ -71,7 +77,7 @@ type App interface {
Settings() *Settings
// Store returns the app runtime store.
Store() *store.Store[any]
Store() *store.Store[string, any]
// Cron returns the app cron instance.
Cron() *cron.Cron
@@ -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
// ---------------------------------------------------------------
@@ -369,16 +409,9 @@ type App interface {
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [App.ReloadCachedCollections()]
// To manually reload the cache you can call [App.ReloadCachedCollections]
FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error)
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
IsCollectionNameUnique(name string, excludeIds ...string) bool
// FindCollectionReferences returns information for all relation
// fields referencing the provided collection.
//
@@ -387,6 +420,32 @@ type App interface {
// as the excludeIds argument.
FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
// FindCachedCollectionReferences is similar to [App.FindCollectionReferences]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// If you plan making changes to the returned Collection model,
// use [App.FindCollectionReferences] instead.
//
// Caveats:
//
// - The returned Collection should be used only for read-only operations.
// Avoid directly modifying the returned cached Collection as it will affect
// the global cached value even if you don't persist the changes in the database!
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [App.ReloadCachedCollections].
FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
IsCollectionNameUnique(name string, excludeIds ...string) bool
// TruncateCollection deletes all records associated with the provided collection.
//
// The truncate operation is executed in a single transaction,
@@ -596,7 +655,7 @@ type App interface {
// CanAccessRecord checks if a record is allowed to be accessed by the
// specified requestInfo and accessRule.
//
// Rule and db checks are ignored in case requestInfo.AuthRecord is a superuser.
// Rule and db checks are ignored in case requestInfo.Auth is a superuser.
//
// The returned error indicate that something unexpected happened during
// the check (eg. invalid rule or db query error).
@@ -633,17 +692,19 @@ type App interface {
// App event hooks
// ---------------------------------------------------------------
// OnBootstrap hook is triggered on initializing the main application
// OnBootstrap hook is triggered when initializing the main application
// resources (db, app settings, etc).
OnBootstrap() *hook.Hook[*BootstrapEvent]
// OnServe hook is triggered on when the app web server is started
// (after starting the tcp listener but before initializing the blocking serve task),
// OnServe hook is triggered when the app web server is started
// (after starting the TCP listener but before initializing the blocking serve task),
// allowing you to adjust its options and attach new routes or middlewares.
OnServe() *hook.Hook[*ServeEvent]
// OnTerminate hook is triggered when the app is in the process
// of being terminated (ex. on SIGTERM signal).
//
// Note that the app could be terminated abruptly without awaiting the hook completion.
OnTerminate() *hook.Hook[*TerminateEvent]
// OnBackupCreate hook is triggered on each [App.CreateBackup] call.
@@ -661,6 +722,9 @@ type App interface {
// OnModelValidate is triggered every time when a model is being validated
// (e.g. triggered by App.Validate() or App.Save()).
//
// For convenience, if you want to listen to only the Record models
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
//
// If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
@@ -680,7 +744,7 @@ type App interface {
// Note that successful execution doesn't guarantee that the model
// is persisted in the database since its wrapping transaction may
// not have been committed yet.
// If you wan to listen to only the actual persisted events, you can
// If you want to listen to only the actual persisted events, you can
// bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
//
// For convenience, if you want to listen to only the Record models
@@ -703,7 +767,7 @@ type App interface {
// Note that successful execution doesn't guarantee that the model
// is persisted in the database since its wrapping transaction may have been
// committed yet.
// If you wan to listen to only the actual persisted events,
// If you want to listen to only the actual persisted events,
// you can bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
//
// For convenience, if you want to listen to only the Record models
@@ -718,7 +782,7 @@ type App interface {
// Model DB create persistence.
//
// Note that when a Model is persisted as part of a transaction,
// this hook is triggered AFTER the transaction has been committed.
// this hook is delayed and executed only AFTER the transaction has been committed.
// This hook is NOT triggered in case the transaction rollbacks
// (aka. when the model wasn't persisted).
//
@@ -732,10 +796,11 @@ type App interface {
// OnModelAfterCreateError is triggered after each failed
// Model DB create persistence.
// Note that when a Model is persisted as part of a transaction,
// this hook is triggered in one of the following cases:
// - immediately after App.Save() failure
// - on transaction rollback
//
// Note that the execution of this hook is either immediate or delayed
// depending on the error:
// - "immediate" on App.Save() failure
// - "delayed" on transaction rollback
//
// For convenience, if you want to listen to only the Record models
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
@@ -759,7 +824,7 @@ type App interface {
// Note that successful execution doesn't guarantee that the model
// is persisted in the database since its wrapping transaction may
// not have been committed yet.
// If you wan to listen to only the actual persisted events, you can
// If you want to listen to only the actual persisted events, you can
// bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
//
// For convenience, if you want to listen to only the Record models
@@ -782,7 +847,7 @@ type App interface {
// Note that successful execution doesn't guarantee that the model
// is persisted in the database since its wrapping transaction may have been
// committed yet.
// If you wan to listen to only the actual persisted events,
// If you want to listen to only the actual persisted events,
// you can bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
//
// For convenience, if you want to listen to only the Record models
@@ -797,7 +862,7 @@ type App interface {
// Model DB update persistence.
//
// Note that when a Model is persisted as part of a transaction,
// this hook is triggered AFTER the transaction has been committed.
// this hook is delayed and executed only AFTER the transaction has been committed.
// This hook is NOT triggered in case the transaction rollbacks
// (aka. when the model changes weren't persisted).
//
@@ -812,10 +877,10 @@ type App interface {
// OnModelAfterUpdateError is triggered after each failed
// Model DB update persistence.
//
// Note that when a Model is persisted as part of a transaction,
// this hook is triggered in one of the following cases:
// - immediately after App.Save() failure
// - on transaction rollback
// Note that the execution of this hook is either immediate or delayed
// depending on the error:
// - "immediate" on App.Save() failure
// - "delayed" on transaction rollback
//
// For convenience, if you want to listen to only the Record models
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
@@ -833,7 +898,7 @@ type App interface {
// Note that successful execution doesn't guarantee that the model
// is deleted from the database since its wrapping transaction may
// not have been committed yet.
// If you wan to listen to only the actual persisted deleted events, you can
// If you want to listen to only the actual persisted deleted events, you can
// bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
//
// For convenience, if you want to listen to only the Record models
@@ -856,7 +921,7 @@ type App interface {
// Note that successful execution doesn't guarantee that the model
// is deleted from the database since its wrapping transaction may
// not have been committed yet.
// If you wan to listen to only the actual persisted deleted events, you can
// If you want to listen to only the actual persisted deleted events, you can
// bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
//
// For convenience, if you want to listen to only the Record models
@@ -871,7 +936,7 @@ type App interface {
// Model DB delete persistence.
//
// Note that when a Model is deleted as part of a transaction,
// this hook is triggered AFTER the transaction has been committed.
// this hook is delayed and executed only AFTER the transaction has been committed.
// This hook is NOT triggered in case the transaction rollbacks
// (aka. when the model delete wasn't persisted).
//
@@ -886,10 +951,10 @@ type App interface {
// OnModelAfterDeleteError is triggered after each failed
// Model DB delete persistence.
//
// Note that when a Model is deleted as part of a transaction,
// this hook is triggered in one of the following cases:
// - immediately after App.Delete() failure
// - on transaction rollback
// Note that the execution of this hook is either immediate or delayed
// depending on the error:
// - "immediate" on App.Delete() failure
// - "delayed" on transaction rollback
//
// For convenience, if you want to listen to only the Record models
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
@@ -904,10 +969,9 @@ type App interface {
// ---------------------------------------------------------------
// OnRecordEnrich is triggered every time when a record is enriched
// (during realtime message seriazation, as part of the builtin Record
// responses, or when [apis.EnrichRecord] is invoked).
// (as part of the builtin Record responses, during realtime message seriazation, or when [apis.EnrichRecord] is invoked).
//
// It could be used for example to redact/hide or add computed temp
// It could be used for example to redact/hide or add computed temporary
// Record model props only for the specific request info. For example:
//
// app.OnRecordEnrich("posts").BindFunc(func(e core.*RecordEnrichEvent) {
@@ -928,7 +992,7 @@ type App interface {
// triggered and called only if their event data origin matches the tags.
OnRecordEnrich(tags ...string) *hook.TaggedHook[*RecordEnrichEvent]
// OnRecordValidate is a proxy Record model hook for [OnModelValidate].
// OnRecordValidate is a Record proxy model hook of [OnModelValidate].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -937,28 +1001,28 @@ type App interface {
// ---------------------------------------------------------------
// OnRecordCreate is a proxy Record model hook for [OnModelCreate].
// OnRecordCreate is a Record proxy model hook of [OnModelCreate].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordCreate(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordCreateExecute is a proxy Record model hook for [OnModelCreateExecute].
// OnRecordCreateExecute is a Record proxy model hook of [OnModelCreateExecute].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordCreateExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordAfterCreateSuccess is a proxy Record model hook for [OnModelAfterCreateSuccess].
// OnRecordAfterCreateSuccess is a Record proxy model hook of [OnModelAfterCreateSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterCreateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordAfterCreateError is a proxy Record model hook for [OnModelAfterCreateError].
// OnRecordAfterCreateError is a Record proxy model hook of [OnModelAfterCreateError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -967,28 +1031,28 @@ type App interface {
// ---------------------------------------------------------------
// OnRecordUpdate is a proxy Record model hook for [OnModelUpdate].
// OnRecordUpdate is a Record proxy model hook of [OnModelUpdate].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordUpdate(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordUpdateExecute is a proxy Record model hook for [OnModelUpdateExecute].
// OnRecordUpdateExecute is a Record proxy model hook of [OnModelUpdateExecute].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordUpdateExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordAfterUpdateSuccess is a proxy Record model hook for [OnModelAfterUpdateSuccess].
// OnRecordAfterUpdateSuccess is a Record proxy model hook of [OnModelAfterUpdateSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordAfterUpdateError is a proxy Record model hook for [OnModelAfterUpdateError].
// OnRecordAfterUpdateError is a Record proxy model hook of [OnModelAfterUpdateError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -997,28 +1061,28 @@ type App interface {
// ---------------------------------------------------------------
// OnRecordDelete is a proxy Record model hook for [OnModelDelete].
// OnRecordDelete is a Record proxy model hook of [OnModelDelete].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordDelete(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordDeleteExecute is a proxy Record model hook for [OnModelDeleteExecute].
// OnRecordDeleteExecute is a Record proxy model hook of [OnModelDeleteExecute].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordDeleteExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordAfterDeleteSuccess is a proxy Record model hook for [OnModelAfterDeleteSuccess].
// OnRecordAfterDeleteSuccess is a Record proxy model hook of [OnModelAfterDeleteSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
// OnRecordAfterDeleteError is a proxy Record model hook for [OnModelAfterDeleteError].
// OnRecordAfterDeleteError is a Record proxy model hook of [OnModelAfterDeleteError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -1029,7 +1093,7 @@ type App interface {
// Collection models event hooks
// ---------------------------------------------------------------
// OnCollectionValidate is a proxy Collection model hook for [OnModelValidate].
// OnCollectionValidate is a Collection proxy model hook of [OnModelValidate].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -1038,28 +1102,28 @@ type App interface {
// ---------------------------------------------------------------
// OnCollectionCreate is a proxy Collection model hook for [OnModelCreate].
// OnCollectionCreate is a Collection proxy model hook of [OnModelCreate].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionCreate(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionCreateExecute is a proxy Collection model hook for [OnModelCreateExecute].
// OnCollectionCreateExecute is a Collection proxy model hook of [OnModelCreateExecute].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionCreateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionAfterCreateSuccess is a proxy Collection model hook for [OnModelAfterCreateSuccess].
// OnCollectionAfterCreateSuccess is a Collection proxy model hook of [OnModelAfterCreateSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionAfterCreateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionAfterCreateError is a proxy Collection model hook for [OnModelAfterCreateError].
// OnCollectionAfterCreateError is a Collection proxy model hook of [OnModelAfterCreateError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -1068,28 +1132,28 @@ type App interface {
// ---------------------------------------------------------------
// OnCollectionUpdate is a proxy Collection model hook for [OnModelUpdate].
// OnCollectionUpdate is a Collection proxy model hook of [OnModelUpdate].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionUpdate(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionUpdateExecute is a proxy Collection model hook for [OnModelUpdateExecute].
// OnCollectionUpdateExecute is a Collection proxy model hook of [OnModelUpdateExecute].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionUpdateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionAfterUpdateSuccess is a proxy Collection model hook for [OnModelAfterUpdateSuccess].
// OnCollectionAfterUpdateSuccess is a Collection proxy model hook of [OnModelAfterUpdateSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionAfterUpdateError is a proxy Collection model hook for [OnModelAfterUpdateError].
// OnCollectionAfterUpdateError is a Collection proxy model hook of [OnModelAfterUpdateError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -1098,28 +1162,28 @@ type App interface {
// ---------------------------------------------------------------
// OnCollectionDelete is a proxy Collection model hook for [OnModelDelete].
// OnCollectionDelete is a Collection proxy model hook of [OnModelDelete].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionDelete(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionDeleteExecute is a proxy Collection model hook for [OnModelDeleteExecute].
// OnCollectionDeleteExecute is a Collection proxy model hook of [OnModelDeleteExecute].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionDeleteExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionAfterDeleteSuccess is a proxy Collection model hook for [OnModelAfterDeleteSuccess].
// OnCollectionAfterDeleteSuccess is a Collection proxy model hook of [OnModelAfterDeleteSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnCollectionAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
// OnCollectionAfterDeleteError is a proxy Collection model hook for [OnModelAfterDeleteError].
// OnCollectionAfterDeleteError is a Collection proxy model hook of [OnModelAfterDeleteError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -1131,7 +1195,7 @@ type App interface {
// ---------------------------------------------------------------
// OnMailerSend hook is triggered every time when a new email is
// being send using the App.NewMailClient() instance.
// being send using the [App.NewMailClient()] instance.
//
// It allows intercepting the email message or to use a custom mailer client.
OnMailerSend() *hook.Hook[*MailerEvent]
@@ -1187,7 +1251,7 @@ type App interface {
// OnRealtimeConnectRequest hook is triggered when establishing the SSE client connection.
//
// Any execution after [e.Next()] of a hook handler happens after the client disconnects.
// Any execution after e.Next() of a hook handler happens after the client disconnects.
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectRequestEvent]
// OnRealtimeMessageSend hook is triggered when sending an SSE message to a client.
@@ -1216,7 +1280,7 @@ type App interface {
// OnSettingsReload hook is triggered every time when the App.Settings()
// is being replaced with a new state.
//
// Calling App.Settings() after e.Next() should return the new state.
// Calling App.Settings() after e.Next() returns the new state.
OnSettingsReload() *hook.Hook[*SettingsReloadEvent]
// ---------------------------------------------------------------
@@ -1229,8 +1293,12 @@ type App interface {
// returning it to the client.
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadRequestEvent]
// OnFileBeforeTokenRequest hook is triggered on each file token API request.
OnFileTokenRequest() *hook.Hook[*FileTokenRequestEvent]
// OnFileBeforeTokenRequest hook is triggered on each auth file token API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnFileTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenRequestEvent]
// ---------------------------------------------------------------
// Record Auth API event hooks
@@ -1250,9 +1318,8 @@ type App interface {
// OnRecordAuthWithPasswordRequest hook is triggered on each
// Record auth with password API request.
//
// RecordAuthWithPasswordRequestEvent.Record could be nil if no
// matching identity is found, allowing you to manually locate a different
// Record model (by reassigning [RecordAuthWithPasswordRequestEvent.Record]).
// [RecordAuthWithPasswordRequestEvent.Record] could be nil if no matching identity is found, allowing
// you to manually locate a different Record model (by reassigning [RecordAuthWithPasswordRequestEvent.Record]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -1262,7 +1329,7 @@ type App interface {
// OnRecordAuthWithOAuth2Request hook is triggered on each Record
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
//
// If the [RecordAuthWithOAuth2RequestEvent.Record] is not set, then the OAuth2
// If [RecordAuthWithOAuth2RequestEvent.Record] is not set, then the OAuth2
// request will try to create a new auth Record.
//
// To assign or link a different existing record model you can
@@ -1353,6 +1420,9 @@ type App interface {
// OnRecordRequestOTPRequest hook is triggered on each Record
// request OTP API request.
//
// [RecordCreateOTPRequestEvent.Record] could be nil if no matching identity is found, allowing
// you to manually create or locate a different Record model (by reassigning [RecordCreateOTPRequestEvent.Record]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
+176 -50
View File
@@ -26,6 +26,8 @@ import (
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/sync/semaphore"
)
const (
@@ -69,8 +71,8 @@ var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct {
config *BaseAppConfig
txInfo *txAppInfo
store *store.Store[any]
txInfo *TxAppInfo
store *store.Store[string, any]
cron *cron.Cron
settings *Settings
subscriptionsBroker *subscriptions.Broker
@@ -194,7 +196,7 @@ type BaseApp struct {
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
settings: newDefaultSettings(),
store: store.New[any](nil),
store: store.New[string, any](nil),
cron: cron.New(),
subscriptionsBroker: subscriptions.NewBroker(),
config: &config,
@@ -202,7 +204,7 @@ func NewBaseApp(config BaseAppConfig) *BaseApp {
// apply config defaults
if app.config.DBConnect == nil {
app.config.DBConnect = dbConnect
app.config.DBConnect = DefaultDBConnect
}
if app.config.DataMaxOpenConns <= 0 {
app.config.DataMaxOpenConns = DefaultDataMaxOpenConns
@@ -340,8 +342,6 @@ func (app *BaseApp) initHooks() {
// NB! Note that using the returned app instance may cause data integrity errors
// since the Record validations and data normalizations (including files uploads)
// rely on the app hooks to work.
//
// @todo consider caching the created instance?
func (app *BaseApp) UnsafeWithoutHooks() App {
clone := *app
@@ -362,9 +362,17 @@ func (app *BaseApp) Logger() *slog.Logger {
return app.logger
}
// TxInfo returns the transaction associated with the current app instance (if any).
//
// Could be used if you want to execute indirectly a function after
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
func (app *BaseApp) TxInfo() *TxAppInfo {
return app.txInfo
}
// IsTransactional checks if the current app instance is part of a transaction.
func (app *BaseApp) IsTransactional() bool {
return app.txInfo != nil
return app.TxInfo() != nil
}
// IsBootstrapped checks if the application was initialized
@@ -468,44 +476,100 @@ func (app *BaseApp) ResetBootstrapState() error {
return nil
}
// DB returns the default app data db instance (pb_data/data.db).
// DB returns the default app data.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly ConcurrentDB() or NonconcurrentDB().
func (app *BaseApp) DB() dbx.Builder {
// transactional or both are nil
if app.concurrentDB == app.nonconcurrentDB {
return app.concurrentDB
}
return &dualDBBuilder{
concurrentDB: app.concurrentDB,
nonconcurrentDB: app.nonconcurrentDB,
}
}
// ConcurrentDB returns the concurrent app data.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) ConcurrentDB() dbx.Builder {
return app.concurrentDB
}
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) NonconcurrentDB() dbx.Builder {
return app.nonconcurrentDB
}
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
// AuxDB returns the app auxiliary.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
func (app *BaseApp) AuxDB() dbx.Builder {
// transactional or both are nil
if app.auxConcurrentDB == app.auxNonconcurrentDB {
return app.auxConcurrentDB
}
return &dualDBBuilder{
concurrentDB: app.auxConcurrentDB,
nonconcurrentDB: app.auxNonconcurrentDB,
}
}
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxConcurrentDB() dbx.Builder {
return app.auxConcurrentDB
}
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db).
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
return app.auxNonconcurrentDB
}
@@ -534,7 +598,7 @@ func (app *BaseApp) Settings() *Settings {
}
// Store returns the app runtime store.
func (app *BaseApp) Store() *store.Store[any] {
func (app *BaseApp) Store() *store.Store[string, any] {
return app.store
}
@@ -603,6 +667,14 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
}
if len(ae.Message.InlineAttachments) > 0 {
attachmentKeys := make([]string, 0, len(ae.Message.InlineAttachments))
for k := range ae.Message.InlineAttachments {
attachmentKeys = append(attachmentKeys, k)
}
fmt.Fprintf(mailLog, "├─ InlineAttachments: %v\n", attachmentKeys)
}
const indentation = " "
if ae.Message.Text != "" {
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
@@ -653,7 +725,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
// NewFilesystem creates a new local or S3 filesystem instance
// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
@@ -981,8 +1053,8 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}
func (app *BaseApp) OnFileTokenRequest() *hook.Hook[*FileTokenRequestEvent] {
return app.onFileTokenRequest
func (app *BaseApp) OnFileTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenRequestEvent] {
return hook.NewTaggedHook(app.onFileTokenRequest, tags...)
}
// -------------------------------------------------------------------
@@ -1133,26 +1205,25 @@ func (app *BaseApp) initDataDB() error {
return nil
}
var sqlLogReplacements = map[string]string{
"{{": "`",
"}}": "`",
"[[": "`",
"]]": "`",
"<nil>": "NULL",
var sqlLogReplacements = []struct {
pattern *regexp.Regexp
replacement string
}{
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
{regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
{regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
{regexp.MustCompile(`<nil>`), "NULL"},
}
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[(.+)\.(.+)\]\]`)
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{(.+)\.(.+)\}\}`)
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
// The query is still not suitable for execution and should be used only for log and debug purposes
// (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string {
sql = sqlLogPrefixedTableIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
sql = sqlLogPrefixedColumnIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
for old, new := range sqlLogReplacements {
sql = strings.ReplaceAll(sql, old, new)
for _, item := range sqlLogReplacements {
sql = item.pattern.ReplaceAllString(sql, item.replacement)
}
return sql
@@ -1185,6 +1256,33 @@ func (app *BaseApp) initAuxDB() error {
return nil
}
// @todo remove after refactoring the FilesManager interface
func supportFiles(m Model) bool {
var collection *Collection
switch v := m.(type) {
case *Collection:
collection = v
case *Record:
collection = v.Collection()
case RecordProxy:
if v.ProxyRecord() != nil {
collection = v.ProxyRecord().Collection()
}
}
if collection == nil {
return true
}
for _, f := range collection.Fields {
if f.Type() == FieldTypeFile {
return true
}
}
return false
}
func (app *BaseApp) registerBaseHooks() {
deletePrefix := func(prefix string) error {
fs, err := app.NewFilesystem()
@@ -1201,26 +1299,44 @@ func (app *BaseApp) registerBaseHooks() {
return nil
}
maxFilesDeleteWorkers := cast.ToInt64(os.Getenv("PB_FILES_DELETE_MAX_WORKERS"))
if maxFilesDeleteWorkers <= 0 {
maxFilesDeleteWorkers = 2000 // the value is arbitrary chosen and may change in the future
}
deleteSem := semaphore.NewWeighted(maxFilesDeleteWorkers)
// try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
Id: "__pbFilesManagerDelete__",
Func: func(e *ModelEvent) error {
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" {
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" && supportFiles(e.Model) {
// ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
// (https://github.com/pocketbase/pocketbase/discussions/5246#discussioncomment-10128955)
prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"
// run in the background for "optimistic" delete to avoid
// blocking the delete transaction
routine.FireAndForget(func() {
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
}
})
// note: for now assume no context cancellation
err := deleteSem.Acquire(context.Background(), 1)
if err != nil {
app.Logger().Error(
"Failed to delete storage prefix (couldn't acquire a worker)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
} else {
// run in the background for "optimistic" delete to avoid blocking the delete transaction
routine.FireAndForget(func() {
defer deleteSem.Release(1)
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
}
})
}
}
return e.Next()
@@ -1238,8 +1354,18 @@ func (app *BaseApp) registerBaseHooks() {
Priority: 999,
})
app.Cron().Add("__pbPragmaOptimize__", "0 0 * * *", func() {
_, execErr := app.DB().NewQuery("PRAGMA optimize").Execute()
app.Cron().Add("__pbDBOptimize__", "0 0 * * *", func() {
_, execErr := app.NonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the main DB", slog.String("error", execErr.Error()))
}
_, execErr = app.AuxNonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
}
_, execErr = app.ConcurrentDB().NewQuery("PRAGMA optimize").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
}
+69 -39
View File
@@ -76,6 +76,11 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
createErr := e.App.RunInTransaction(func(txApp App) error {
return txApp.AuxRunInTransaction(func(txApp App) error {
// run manual checkpoint and truncate the WAL files
// (errors are ignored because it is not that important and the PRAGMA may not be supported by the used driver)
txApp.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
txApp.AuxDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
return archive.Create(txApp.DataDir(), tempPath, e.Exclude...)
})
})
@@ -137,6 +142,10 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
//
// If a failure occure during the restore process the dir changes are reverted.
// If for whatever reason the revert is not possible, it panics.
//
// Note that if your pb_data has custom network mounts as subdirectories, then
// it is possible the restore to fail during the `os.Rename` operations
// (see https://github.com/pocketbase/pocketbase/issues/4647).
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
@@ -157,6 +166,13 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return errors.New("restore is not supported on Windows")
}
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
return err
@@ -165,57 +181,71 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
fsys.SetContext(e.Context)
// fetch the backup file in a temp location
br, err := fsys.GetFile(name)
if err != nil {
return err
}
defer br.Close()
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
if ok, _ := fsys.Exists(name); !ok {
return fmt.Errorf("missing or invalid backup file %q to restore", name)
}
// create a temp zip file from the blob.Reader and try to extract it
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
if err != nil {
return err
}
defer os.Remove(tempZip.Name())
if _, err := io.Copy(tempZip, br); err != nil {
return err
}
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
defer os.RemoveAll(extractedDataDir)
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
return err
// extract the zip
if e.App.Settings().Backups.S3.Enabled {
br, err := fsys.GetReader(name)
if err != nil {
return err
}
defer br.Close()
// create a temp zip file from the blob.Reader and try to extract it
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
if err != nil {
return err
}
defer os.Remove(tempZip.Name())
defer tempZip.Close() // note: this technically shouldn't be necessary but it is here to workaround platforms discrepancies
_, err = io.Copy(tempZip, br)
if err != nil {
return err
}
err = archive.Extract(tempZip.Name(), extractedDataDir)
if err != nil {
return err
}
// remove the temp zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
_ = tempZip.Close()
err = os.Remove(tempZip.Name())
if err != nil {
e.App.Logger().Warn(
"[RestoreBackup] Failed to remove the temp zip backup file",
slog.String("file", tempZip.Name()),
slog.String("error", err.Error()),
)
}
} else {
// manually construct the local path to avoid creating a copy of the zip file
// since the blob reader currently doesn't implement ReaderAt
zipPath := filepath.Join(app.DataDir(), LocalBackupsDirName, filepath.Base(name))
err = archive.Extract(zipPath, extractedDataDir)
if err != nil {
return err
}
}
// ensure that a database file exists
// ensure that at least a database file exists
extractedDB := filepath.Join(extractedDataDir, "data.db")
if _, err := os.Stat(extractedDB); err != nil {
return fmt.Errorf("data.db file is missing or invalid: %w", err)
}
// remove the extracted zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
if err := os.Remove(tempZip.Name()); err != nil {
e.App.Logger().Debug(
"[RestoreBackup] Failed to remove the temp zip backup file",
slog.String("file", tempZip.Name()),
slog.String("error", err.Error()),
)
}
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
if err := osutils.MoveDirContent(e.App.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
@@ -252,7 +282,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// registerAutobackupHooks registers the autobackup app serve hooks.
func (app *BaseApp) registerAutobackupHooks() {
const jobId = "__auto_pb_backup__"
const jobId = "__pbAutoBackup__"
loadJob := func() {
rawSchedule := app.Settings().Backups.Cron
+191 -9
View File
@@ -2,13 +2,16 @@ package core_test
import (
"context"
"database/sql"
"log/slog"
"os"
"slices"
"testing"
"time"
_ "unsafe"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/logger"
@@ -97,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},
@@ -114,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},
@@ -126,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)
@@ -139,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) {
@@ -351,6 +375,12 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
t.Fatal(err)
}
// silence query logs
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) {}
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
if !ok {
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
@@ -370,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)
}
}
}
}
+6
View File
@@ -86,6 +86,12 @@ func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing b
continue
}
if imported.Fields.GetById(f.GetId()) == nil {
// replace with the existing id to prevent accidental column deletion
// since otherwise the imported field will be treated as a new one
found := imported.Fields.GetByName(f.GetName())
if found != nil && found.Type() == f.Type() {
found.SetId(f.GetId())
}
imported.Fields.Add(f)
}
}
+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,
+208 -84
View File
@@ -3,8 +3,10 @@ package core
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/security"
@@ -26,15 +28,20 @@ const (
const systemHookIdCollection = "__pbCollectionSystemHook__"
const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
func (app *BaseApp) registerCollectionHooks() {
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -46,10 +53,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -61,10 +71,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -76,10 +89,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -91,10 +107,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
return me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
@@ -106,10 +125,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -121,10 +143,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -136,10 +161,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -151,10 +179,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
return me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
@@ -166,10 +197,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -181,10 +215,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -196,10 +233,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
return me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
@@ -211,10 +251,13 @@ func (app *BaseApp) registerCollectionHooks() {
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
return me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
@@ -309,6 +352,7 @@ type baseCollection struct {
BaseModel
disableIntegrityChecks bool
autogeneratedId string
ListRule *string `db:"listRule" json:"listRule" form:"listRule"`
ViewRule *string `db:"viewRule" json:"viewRule" form:"viewRule"`
@@ -325,9 +369,12 @@ type baseCollection struct {
Type string `db:"type" json:"type" form:"type"`
Fields FieldsList `db:"fields" json:"fields" form:"fields"`
Indexes types.JSONArray[string] `db:"indexes" json:"indexes" form:"indexes"`
System bool `db:"system" json:"system" form:"system"`
Created types.DateTime `db:"created" json:"created"`
Updated types.DateTime `db:"updated" json:"updated"`
// System prevents the collection rename, deletion and rules change.
// It is used primarily for internal purposes for collections like "_superusers", "_externalAuths", etc.
System bool `db:"system" json:"system" form:"system"`
}
// Collection defines the table, fields and various options related to a set of records.
@@ -338,45 +385,78 @@ type Collection struct {
}
// NewCollection initializes and returns a new Collection model with the specified type and name.
func NewCollection(typ, name string) *Collection {
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewCollection(typ, name string, optId ...string) *Collection {
switch typ {
case CollectionTypeAuth:
return NewAuthCollection(name)
return NewAuthCollection(name, optId...)
case CollectionTypeView:
return NewViewCollection(name)
return NewViewCollection(name, optId...)
default:
return NewBaseCollection(name)
return NewBaseCollection(name, optId...)
}
}
// NewBaseCollection initializes and returns a new "base" Collection model.
func NewBaseCollection(name string) *Collection {
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewBaseCollection(name string, optId ...string) *Collection {
m := &Collection{}
m.Name = name
m.Type = CollectionTypeBase
// @todo consider removing once inferred composite literals are supported
if len(optId) > 0 {
m.Id = optId[0]
}
m.initDefaultId()
m.initDefaultFields()
return m
}
// NewViewCollection initializes and returns a new "view" Collection model.
func NewViewCollection(name string) *Collection {
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewViewCollection(name string, optId ...string) *Collection {
m := &Collection{}
m.Name = name
m.Type = CollectionTypeView
// @todo consider removing once inferred composite literals are supported
if len(optId) > 0 {
m.Id = optId[0]
}
m.initDefaultId()
m.initDefaultFields()
return m
}
// NewAuthCollection initializes and returns a new "auth" Collection model.
func NewAuthCollection(name string) *Collection {
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewAuthCollection(name string, optId ...string) *Collection {
m := &Collection{}
m.Name = name
m.Type = CollectionTypeAuth
// @todo consider removing once inferred composite literals are supported
if len(optId) > 0 {
m.Id = optId[0]
}
m.initDefaultId()
m.initDefaultFields()
m.setDefaultAuthOptions()
return m
}
@@ -449,12 +529,13 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
minimal := &struct {
Type string `json:"type"`
Name string `json:"name"`
Id string `json:"id"`
}{}
if err := json.Unmarshal(b, minimal); err != nil {
return err
}
blank := NewCollection(minimal.Type, minimal.Name)
blank := NewCollection(minimal.Type, minimal.Name, minimal.Id)
*m = *blank
}
@@ -656,42 +737,82 @@ func onCollectionDeleteExecute(e *CollectionEvent) error {
// save hook
// -------------------------------------------------------------------
func (c *Collection) idChecksum() string {
return "pbc_" + crc32Checksum(c.Type+c.Name)
}
func (c *Collection) initDefaultId() {
if c.Id == "" && c.Name != "" {
c.Id = "_pbc_" + crc32Checksum(c.Name)
if c.Id == "" {
c.Id = c.idChecksum()
c.autogeneratedId = c.Id
}
}
func (c *Collection) savePrepare() error {
if c.Type == "" {
c.Type = CollectionTypeBase
func (c *Collection) updateGeneratedIdIfExists(app App) {
if !c.IsNew() ||
// the id was explicitly cleared
c.Id == "" ||
// the id was manually set
c.Id != c.autogeneratedId {
return
}
if c.IsNew() {
c.initDefaultId()
c.Created = types.NowDateTime()
// generate an up-to-date checksum
newId := c.idChecksum()
// add a number to the current id (if already exists)
for i := 2; i < 1000; i++ {
var exists int
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
if exists == 0 {
break
}
newId = c.idChecksum() + strconv.Itoa(i)
}
c.Updated = types.NowDateTime()
// recreate the fields list to ensure that all normalizations
// like default field id are applied
c.Fields = NewFieldsList(c.Fields...)
c.initDefaultFields()
if c.IsAuth() {
c.unsetMissingOAuth2MappedFields()
// no change
if c.Id == newId {
return
}
return nil
// replace the old id in the index names (if any)
for i, idx := range c.Indexes {
parsed := dbutils.ParseIndex(idx)
original := parsed.IndexName
parsed.IndexName = strings.ReplaceAll(parsed.IndexName, c.Id, newId)
if parsed.IndexName != original {
c.Indexes[i] = parsed.Build()
}
}
// update model id
c.Id = newId
}
func onCollectionSave(e *CollectionEvent) error {
if err := e.Collection.savePrepare(); err != nil {
return err
if e.Collection.Type == "" {
e.Collection.Type = CollectionTypeBase
}
if e.Collection.IsNew() {
e.Collection.initDefaultId()
e.Collection.Created = types.NowDateTime()
}
e.Collection.Updated = types.NowDateTime()
// recreate the fields list to ensure that all normalizations
// like default field id are applied
e.Collection.Fields = NewFieldsList(e.Collection.Fields...)
e.Collection.initDefaultFields()
if e.Collection.IsAuth() {
e.Collection.unsetMissingOAuth2MappedFields()
}
e.Collection.updateGeneratedIdIfExists(e.App)
return e.Next()
}
@@ -783,24 +904,24 @@ func onCollectionSaveExecute(e *CollectionEvent) error {
return nil
}
func (m *Collection) initDefaultFields() {
switch m.Type {
func (c *Collection) initDefaultFields() {
switch c.Type {
case CollectionTypeBase:
m.initIdField()
c.initIdField()
case CollectionTypeAuth:
m.initIdField()
m.initPasswordField()
m.initTokenKeyField()
m.initEmailField()
m.initEmailVisibilityField()
m.initVerifiedField()
c.initIdField()
c.initPasswordField()
c.initTokenKeyField()
c.initEmailField()
c.initEmailVisibilityField()
c.initVerifiedField()
case CollectionTypeView:
// view fields are autogenerated
}
}
func (m *Collection) initIdField() {
field, _ := m.Fields.GetByName(FieldNameId).(*TextField)
func (c *Collection) initIdField() {
field, _ := c.Fields.GetByName(FieldNameId).(*TextField)
if field == nil {
// create default field
field = &TextField{
@@ -810,26 +931,29 @@ func (m *Collection) initIdField() {
Required: true,
Min: 15,
Max: 15,
Pattern: `^[a-z0-9]+$`,
Pattern: defaultLowercaseRecordIdPattern,
AutogeneratePattern: `[a-z0-9]{15}`,
}
// prepend it
m.Fields = NewFieldsList(append([]Field{field}, m.Fields...)...)
c.Fields = NewFieldsList(append([]Field{field}, c.Fields...)...)
} else {
// enforce system defaults
field.System = true
field.Required = true
field.PrimaryKey = true
field.Hidden = false
if field.Pattern == "" {
field.Pattern = defaultLowercaseRecordIdPattern
}
}
}
func (m *Collection) initPasswordField() {
field, _ := m.Fields.GetByName(FieldNamePassword).(*PasswordField)
func (c *Collection) initPasswordField() {
field, _ := c.Fields.GetByName(FieldNamePassword).(*PasswordField)
if field == nil {
// load default field
m.Fields.Add(&PasswordField{
c.Fields.Add(&PasswordField{
Name: FieldNamePassword,
System: true,
Hidden: true,
@@ -844,11 +968,11 @@ func (m *Collection) initPasswordField() {
}
}
func (m *Collection) initTokenKeyField() {
field, _ := m.Fields.GetByName(FieldNameTokenKey).(*TextField)
func (c *Collection) initTokenKeyField() {
field, _ := c.Fields.GetByName(FieldNameTokenKey).(*TextField)
if field == nil {
// load default field
m.Fields.Add(&TextField{
c.Fields.Add(&TextField{
Name: FieldNameTokenKey,
System: true,
Hidden: true,
@@ -865,21 +989,21 @@ func (m *Collection) initTokenKeyField() {
}
// ensure that there is a unique index for the field
if !dbutils.HasSingleColumnUniqueIndex(FieldNameTokenKey, m.Indexes) {
m.Indexes = append(m.Indexes, fmt.Sprintf(
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
m.fieldIndexName(FieldNameTokenKey),
m.Name,
c.fieldIndexName(FieldNameTokenKey),
c.Name,
FieldNameTokenKey,
))
}
}
func (m *Collection) initEmailField() {
field, _ := m.Fields.GetByName(FieldNameEmail).(*EmailField)
func (c *Collection) initEmailField() {
field, _ := c.Fields.GetByName(FieldNameEmail).(*EmailField)
if field == nil {
// load default field
m.Fields.Add(&EmailField{
c.Fields.Add(&EmailField{
Name: FieldNameEmail,
System: true,
Required: true,
@@ -891,22 +1015,22 @@ func (m *Collection) initEmailField() {
}
// ensure that there is a unique index for the email field
if !dbutils.HasSingleColumnUniqueIndex(FieldNameEmail, m.Indexes) {
m.Indexes = append(m.Indexes, fmt.Sprintf(
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
m.fieldIndexName(FieldNameEmail),
m.Name,
c.fieldIndexName(FieldNameEmail),
c.Name,
FieldNameEmail,
FieldNameEmail,
))
}
}
func (m *Collection) initEmailVisibilityField() {
field, _ := m.Fields.GetByName(FieldNameEmailVisibility).(*BoolField)
func (c *Collection) initEmailVisibilityField() {
field, _ := c.Fields.GetByName(FieldNameEmailVisibility).(*BoolField)
if field == nil {
// load default field
m.Fields.Add(&BoolField{
c.Fields.Add(&BoolField{
Name: FieldNameEmailVisibility,
System: true,
})
@@ -916,11 +1040,11 @@ func (m *Collection) initEmailVisibilityField() {
}
}
func (m *Collection) initVerifiedField() {
field, _ := m.Fields.GetByName(FieldNameVerified).(*BoolField)
func (c *Collection) initVerifiedField() {
field, _ := c.Fields.GetByName(FieldNameVerified).(*BoolField)
if field == nil {
// load default field
m.Fields.Add(&BoolField{
c.Fields.Add(&BoolField{
Name: FieldNameVerified,
System: true,
})
@@ -930,13 +1054,13 @@ func (m *Collection) initVerifiedField() {
}
}
func (m *Collection) fieldIndexName(field string) string {
func (c *Collection) fieldIndexName(field string) string {
name := "idx_" + field + "_"
if m.Id != "" {
name += m.Id
} else if m.Name != "" {
name += m.Name
if c.Id != "" {
name += c.Id
} else if c.Name != "" {
name += c.Name
} else {
name += security.PseudorandomString(10)
}
+6 -3
View File
@@ -122,10 +122,13 @@ type collectionAuthOptions struct {
// and which OAuth2 providers are allowed.
OAuth2 OAuth2Config `form:"oauth2" json:"oauth2"`
// PasswordAuth defines options related to the collection password authentication.
PasswordAuth PasswordAuthConfig `form:"passwordAuth" json:"passwordAuth"`
// MFA defines options related to the Multi-factor authentication (MFA).
MFA MFAConfig `form:"mfa" json:"mfa"`
// OTP defines options related to the One-time password authentication (OTP).
OTP OTPConfig `form:"otp" json:"otp"`
// Various token configurations
@@ -136,7 +139,7 @@ type collectionAuthOptions struct {
VerificationToken TokenConfig `form:"verificationToken" json:"verificationToken"`
FileToken TokenConfig `form:"fileToken" json:"fileToken"`
// default email templates
// Default email templates
// ---
VerificationTemplate EmailTemplate `form:"verificationTemplate" json:"verificationTemplate"`
ResetPasswordTemplate EmailTemplate `form:"resetPasswordTemplate" json:"resetPasswordTemplate"`
@@ -442,7 +445,7 @@ func checkForDuplicatedProviders(value any) error {
if _, ok := existing[c.Name]; ok {
return validation.Errors{
strconv.Itoa(i): validation.Errors{
"name": validation.NewError("validation_duplicated_provider", "The provider "+c.Name+" is already registered.").
"name": validation.NewError("validation_duplicated_provider", "The provider {{.name}} is already registered.").
SetParams(map[string]any{"name": c.Name}),
},
}
@@ -490,7 +493,7 @@ func checkProviderName(value any) error {
}
if _, err := auth.NewProviderByName(name); err != nil {
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name "+name+".").
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name {{.name}}.").
SetParams(map[string]any{"name": name})
}
+367 -64
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"
)
@@ -26,7 +30,7 @@ func TestNewCollection(t *testing.T) {
"",
"",
[]string{
`"id":""`,
`"id":"pbc_`,
`"name":""`,
`"type":"base"`,
`"system":false`,
@@ -45,7 +49,7 @@ func TestNewCollection(t *testing.T) {
"unknown",
"test",
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"base"`,
`"system":false`,
@@ -64,7 +68,7 @@ func TestNewCollection(t *testing.T) {
"base",
"test",
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"base"`,
`"system":false`,
@@ -83,7 +87,7 @@ func TestNewCollection(t *testing.T) {
"view",
"test",
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"view"`,
`"indexes":[]`,
@@ -100,7 +104,7 @@ func TestNewCollection(t *testing.T) {
"auth",
"test",
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"auth"`,
`"fields":[{`,
@@ -148,7 +152,7 @@ func TestNewBaseCollection(t *testing.T) {
{
"",
[]string{
`"id":""`,
`"id":"pbc_`,
`"name":""`,
`"type":"base"`,
`"system":false`,
@@ -166,7 +170,7 @@ func TestNewBaseCollection(t *testing.T) {
{
"test",
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"base"`,
`"system":false`,
@@ -206,7 +210,7 @@ func TestNewViewCollection(t *testing.T) {
{
"",
[]string{
`"id":""`,
`"id":"pbc_`,
`"name":""`,
`"type":"view"`,
`"indexes":[]`,
@@ -222,7 +226,7 @@ func TestNewViewCollection(t *testing.T) {
{
"test",
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"view"`,
`"indexes":[]`,
@@ -286,7 +290,7 @@ func TestNewAuthCollection(t *testing.T) {
{
"test",
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"auth"`,
`"fields":[{`,
@@ -498,10 +502,11 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
defer app.Cleanup()
scenarios := []struct {
name string
raw string
collection func() *core.Collection
expectedCollection func() *core.Collection
name string
raw string
collection func() *core.Collection
expected []string
notExpected []string
}{
{
"base new empty",
@@ -509,12 +514,18 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
func() *core.Collection {
return &core.Collection{}
},
func() *core.Collection {
c := core.NewBaseCollection("test")
c.ListRule = types.Pointer("1=2")
c.AuthRule = types.Pointer("1=3")
c.ViewQuery = "abc"
return c
[]string{
`"type":"base"`,
`"id":"pbc_`,
`"name":"test"`,
`"listRule":"1=2"`,
`"fields":[`,
`"name":"id"`,
`"indexes":[]`,
},
[]string{
`"authRule":"1=3"`,
`"viewQuery":"abc"`,
},
},
{
@@ -523,12 +534,17 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
func() *core.Collection {
return &core.Collection{}
},
func() *core.Collection {
c := core.NewViewCollection("test")
c.ListRule = types.Pointer("1=2")
c.AuthRule = types.Pointer("1=3")
c.ViewQuery = "abc"
return c
[]string{
`"type":"view"`,
`"id":"pbc_`,
`"name":"test"`,
`"listRule":"1=2"`,
`"fields":[]`,
`"viewQuery":"abc"`,
`"indexes":[]`,
},
[]string{
`"authRule":"1=3"`,
},
},
{
@@ -537,12 +553,18 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
func() *core.Collection {
return &core.Collection{}
},
func() *core.Collection {
c := core.NewAuthCollection("test")
c.ListRule = types.Pointer("1=2")
c.AuthRule = types.Pointer("1=3")
c.ViewQuery = "abc"
return c
[]string{
`"type":"auth"`,
`"id":"pbc_`,
`"name":"test"`,
`"listRule":"1=2"`,
`"authRule":"1=3"`,
`"fields":[`,
`"name":"id"`,
},
[]string{
`"indexes":[]`,
`"viewQuery":"abc"`,
},
},
{
@@ -553,14 +575,16 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
c.Type = core.CollectionTypeBase
return c
},
func() *core.Collection {
c := &core.Collection{}
c.Type = core.CollectionTypeBase
c.Name = "test"
c.ListRule = types.Pointer("1=2")
c.AuthRule = types.Pointer("1=3")
c.ViewQuery = "abc"
return c
[]string{
`"type":"base"`,
`"id":""`,
`"name":"test"`,
`"listRule":"1=2"`,
`"fields":[]`,
},
[]string{
`"authRule":"1=3"`,
`"viewQuery":"abc"`,
},
},
{
@@ -570,14 +594,17 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
c, _ := app.FindCollectionByNameOrId("demo1")
return c
},
func() *core.Collection {
c, _ := app.FindCollectionByNameOrId("demo1")
c.Type = core.CollectionTypeAuth
c.Name = "test"
c.ListRule = types.Pointer("1=2")
c.AuthRule = types.Pointer("1=3")
c.ViewQuery = "abc"
return c
[]string{
`"type":"auth"`,
`"name":"test"`,
`"listRule":"1=2"`,
`"authRule":"1=3"`,
`"fields":[`,
`"name":"id"`,
},
[]string{
`"name":"tokenKey"`,
`"viewQuery":"abc"`,
},
},
}
@@ -597,14 +624,16 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
}
rawResultStr := string(rawResult)
rawExpected, err := json.Marshal(s.expectedCollection())
if err != nil {
t.Fatal(err)
for _, part := range s.expected {
if !strings.Contains(rawResultStr, part) {
t.Fatalf("Missing expected %q in\n%v", part, rawResultStr)
}
}
rawExpectedStr := string(rawExpected)
if rawResultStr != rawExpectedStr {
t.Fatalf("Expected collection\n%s\ngot\n%s", rawExpectedStr, rawResultStr)
for _, part := range s.notExpected {
if strings.Contains(rawResultStr, part) {
t.Fatalf("Didn't expected %q in\n%v", part, rawResultStr)
}
}
})
}
@@ -630,7 +659,7 @@ func TestCollectionSerialize(t *testing.T) {
return c
},
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"base"`,
},
@@ -658,7 +687,7 @@ func TestCollectionSerialize(t *testing.T) {
return c
},
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"view"`,
`"viewQuery":"1=1"`,
@@ -686,7 +715,7 @@ func TestCollectionSerialize(t *testing.T) {
return c
},
[]string{
`"id":"_pbc_3632233996"`,
`"id":"pbc_`,
`"name":"test"`,
`"type":"auth"`,
`"oauth2":{`,
@@ -748,19 +777,19 @@ func TestCollectionDBExport(t *testing.T) {
}{
{
"unknown",
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeBase,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeView,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeAuth,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
}
@@ -782,8 +811,8 @@ func TestCollectionDBExport(t *testing.T) {
c.Updated = date
c.Indexes = types.JSONArray[string]{"CREATE INDEX idx1 on test_name(id)", "CREATE INDEX idx2 on test_name(id)"}
c.ViewQuery = "select 1"
c.Fields.Add(&core.BoolField{Name: "f1", System: true})
c.Fields.Add(&core.BoolField{Name: "f2", Required: true})
c.Fields.Add(&core.BoolField{Id: "f1_id", Name: "f1", System: true})
c.Fields.Add(&core.BoolField{Id: "f2_id", Name: "f2", Required: true})
c.RawOptions = types.JSONRaw(`{"viewQuery": "select 2"}`) // should be ignored
result, err := c.DBExport(app)
@@ -951,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()
@@ -1320,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 {
+73 -30
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/pocketbase/dbx"
@@ -37,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
}
@@ -75,16 +76,16 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
return m, nil
}
// FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// Returns [sql.ErrNoRows] if no Collection is found for consistency
// with the [App.FindCollectionByNameOrId] method.
// with the [BaseApp.FindCollectionByNameOrId] method.
//
// If you plan making changes to the returned Collection model,
// use [App.FindCollectionByNameOrId] instead.
// use [BaseApp.FindCollectionByNameOrId] instead.
//
// Caveats:
//
@@ -94,7 +95,7 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [App.ReloadCachedCollections()]
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
if collections == nil {
@@ -111,30 +112,6 @@ func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection
return nil, sql.ErrNoRows
}
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
}
query := app.CollectionQuery().
Select("count(*)").
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
return query.Row(&exists) == nil && !exists
}
// FindCollectionReferences returns information for all relation fields
// referencing the provided collection.
//
@@ -168,6 +145,72 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
return result, nil
}
// FindCachedCollectionReferences is similar to [BaseApp.FindCollectionReferences]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// If you plan making changes to the returned Collection model,
// use [BaseApp.FindCollectionReferences] instead.
//
// Caveats:
//
// - The returned Collection should be used only for read-only operations.
// Avoid directly modifying the returned cached Collection as it will affect
// the global cached value even if you don't persist the changes in the database!
// - If you are updating a Collection in a transaction and then call this method before commit,
// it'll return the cached Collection state and not the one from the uncommitted transaction.
// - The cache is automatically updated on collections db change (create/update/delete).
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
func (app *BaseApp) FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
if collections == nil {
// cache is not initialized yet (eg. run in a system migration)
return app.FindCollectionReferences(collection, excludeIds...)
}
result := map[*Collection][]Field{}
for _, c := range collections {
if slices.Contains(excludeIds, c.Id) {
continue
}
for _, rawField := range c.Fields {
f, ok := rawField.(*RelationField)
if ok && f.CollectionId == collection.Id {
result[c] = append(result[c], f)
}
}
}
return result, nil
}
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
}
query := app.CollectionQuery().
Select("count(*)").
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var total int
return query.Row(&total) == nil && total == 0
}
// TruncateCollection deletes all records associated with the provided collection.
//
// The truncate operation is executed in a single transaction,
@@ -177,7 +220,7 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
// 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 {
+110 -29
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++
}
@@ -208,34 +208,6 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
run(false)
}
func TestIsCollectionNameUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
excludeId string
expected bool
}{
{"", "", false},
{"demo1", "", false},
{"Demo1", "", false},
{"new", "", true},
{"demo1", "wsmn24bux7wo113", true},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
result := app.IsCollectionNameUnique(s.name, s.excludeId)
if result != s.expected {
t.Errorf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestFindCollectionReferences(t *testing.T) {
t.Parallel()
@@ -288,6 +260,115 @@ func TestFindCollectionReferences(t *testing.T) {
}
}
func TestFindCachedCollectionReferences(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
totalQueries := 0
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
run := func(withCache bool) {
var expectedTotalQueries int
if withCache {
err := app.ReloadCachedCollections()
if err != nil {
t.Fatal(err)
}
} else {
app.Store().Reset(nil)
expectedTotalQueries = 1
}
totalQueries = 0
result, err := app.FindCachedCollectionReferences(
collection,
collection.Id,
// test whether "nonempty" exclude ids condition will be skipped
"",
"",
)
if err != nil {
t.Fatal(err)
}
if len(result) != 1 {
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
}
expectedFields := []string{
"rel_one_no_cascade",
"rel_one_no_cascade_required",
"rel_one_cascade",
"rel_one_unique",
"rel_many_no_cascade",
"rel_many_no_cascade_required",
"rel_many_cascade",
"rel_many_unique",
}
for col, fields := range result {
if col.Name != "demo4" {
t.Fatalf("Expected collection demo4, got %s", col.Name)
}
if len(fields) != len(expectedFields) {
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
}
for i, f := range fields {
if !slices.Contains(expectedFields, f.GetName()) {
t.Fatalf("[%d] Didn't expect field %v", i, f)
}
}
}
if totalQueries != expectedTotalQueries {
t.Fatalf("Expected %d totalQueries, got %d", expectedTotalQueries, totalQueries)
}
}
run(true)
run(false)
}
func TestIsCollectionNameUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
excludeId string
expected bool
}{
{"", "", false},
{"demo1", "", false},
{"Demo1", "", false},
{"new", "", true},
{"demo1", "wsmn24bux7wo113", true},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
result := app.IsCollectionNameUnique(s.name, s.excludeId)
if result != s.expected {
t.Errorf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestFindCollectionTruncate(t *testing.T) {
t.Parallel()
+4 -5
View File
@@ -144,7 +144,7 @@ func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollecti
// run optimize per the SQLite recommendations
// (https://www.sqlite.org/pragma.html#pragma_optimize)
_, optimizeErr := app.DB().NewQuery("PRAGMA optimize").Execute()
_, optimizeErr := app.ConcurrentDB().NewQuery("PRAGMA optimize").Execute()
if optimizeErr != nil {
app.Logger().Warn("Failed to run PRAGMA optimize after record table sync", slog.String("error", optimizeErr.Error()))
}
@@ -310,7 +310,8 @@ func dropCollectionIndexes(app App, collection *Collection) error {
continue
}
if _, err := app.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
if err != nil {
return err
}
}
@@ -349,9 +350,7 @@ func createCollectionIndexes(app App, collection *Collection) error {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
).SetParams(map[string]any{
"indexName": parsed.IndexName,
})
)
continue
}
}
+78 -42
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 {
@@ -65,6 +69,10 @@ type optionsValidator interface {
}
func (validator *collectionValidator) run() error {
if validator.original.IsNew() {
validator.new.updateGeneratedIdIfExists(validator.app)
}
// generate fields from the query (overwriting any explicit user defined fields)
if validator.new.IsView() {
validator.new.Fields, _ = validator.app.CreateViewFields(validator.new.ViewQuery)
@@ -79,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)),
),
@@ -264,7 +272,7 @@ func (validator *collectionValidator) checkFieldDuplicates(value any) error {
strconv.Itoa(i): validation.Errors{
"name": validation.NewError(
"validation_duplicated_field_name",
fmt.Sprintf("Duplicated or invalid field name %q", field.GetName()),
"Duplicated or invalid field name {{.fieldName}}",
).SetParams(map[string]any{
"fieldName": field.GetName(),
}),
@@ -416,6 +424,10 @@ func (validator *collectionValidator) ensureNoSystemFieldsChange(value any) erro
return validators.ErrUnsupportedValueType
}
if validator.original.IsNew() {
return nil // not an update
}
for _, oldField := range validator.original.Fields {
if !oldField.GetSystem() {
continue
@@ -444,12 +456,12 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
for _, name := range names {
field := cv.new.Fields.GetByName(name)
if field == nil {
return validation.NewError("validation_missing_field", fmt.Sprintf("Invalid or missing field %q", name)).
return validation.NewError("validation_missing_field", "Invalid or missing field {{.fieldName}}").
SetParams(map[string]any{"fieldName": name})
}
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
return validation.NewError("validation_missing_unique_constraint", fmt.Sprintf("The field %q doesn't have a UNIQUE constraint.", name)).
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})
}
}
@@ -516,7 +528,8 @@ func (cv *collectionValidator) checkIndexes(value any) error {
)
}
indexNames := make(map[string]struct{}, len(indexes))
duplicatedNames := make(map[string]struct{}, len(indexes))
duplicatedDefinitions := make(map[string]struct{}, len(indexes))
for i, rawIndex := range indexes {
parsed := dbutils.ParseIndex(rawIndex)
@@ -533,19 +546,19 @@ func (cv *collectionValidator) checkIndexes(value any) error {
}
}
_, isDuplicated := indexNames[strings.ToLower(parsed.IndexName)]
if isDuplicated {
if _, isDuplicated := duplicatedNames[strings.ToLower(parsed.IndexName)]; isDuplicated {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_duplicated_index_name",
"The index name must be unique.",
"The index name already exists.",
),
}
}
duplicatedNames[strings.ToLower(parsed.IndexName)] = struct{}{}
// 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})).
@@ -557,10 +570,25 @@ func (cv *collectionValidator) checkIndexes(value any) error {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_existing_index_name",
"The index name is already used in "+usedTblName+" collection.",
"The index name is already used in {{.usedTableName}} collection.",
).SetParams(map[string]any{"usedTableName": usedTblName}),
}
}
// reset non-important identifiers
parsed.SchemaName = "validator"
parsed.IndexName = "validator"
parsedDef := parsed.Build()
if _, isDuplicated := duplicatedDefinitions[parsedDef]; isDuplicated {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_duplicated_index_definition",
"The index definition already exists.",
),
}
}
duplicatedDefinitions[parsedDef] = struct{}{}
// note: we don't check the index table name because it is always
// overwritten by the SyncRecordTableSchema to allow
@@ -573,15 +601,24 @@ func (cv *collectionValidator) checkIndexes(value any) error {
// ),
// }
// }
indexNames[strings.ToLower(parsed.IndexName)] = struct{}{}
}
// ensure that indexes on system fields are not deleted or changed
// ensure that unique indexes on system fields are not changed or removed
if !cv.original.IsNew() {
OLD_INDEXES_LOOP:
for _, oldIndex := range cv.original.Indexes {
oldParsed := dbutils.ParseIndex(oldIndex)
if !oldParsed.Unique {
continue
}
// reset collate and sort since they are not important for the unique constraint
for i := range oldParsed.Columns {
oldParsed.Columns[i].Collate = ""
oldParsed.Columns[i].Sort = ""
}
oldParsedStr := oldParsed.Build()
for _, column := range oldParsed.Columns {
for _, f := range cv.original.Fields {
@@ -589,36 +626,35 @@ func (cv *collectionValidator) checkIndexes(value any) error {
continue
}
var exists bool
for i, newIndex := range cv.new.Indexes {
var hasMatch bool
for _, newIndex := range cv.new.Indexes {
newParsed := dbutils.ParseIndex(newIndex)
if !strings.EqualFold(newParsed.IndexName, oldParsed.IndexName) {
continue
// exclude the non-important identifiers from the check
newParsed.SchemaName = oldParsed.SchemaName
newParsed.IndexName = oldParsed.IndexName
newParsed.TableName = oldParsed.TableName
// exclude partial constraints
newParsed.Where = oldParsed.Where
// reset collate and sort
for i := range newParsed.Columns {
newParsed.Columns[i].Collate = ""
newParsed.Columns[i].Sort = ""
}
// normalize table names of both indexes
oldParsed.TableName = "validator"
newParsed.TableName = "validator"
if oldParsed.Build() != newParsed.Build() {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_system_index_change",
"Indexes on system fields cannot change.",
),
}
if oldParsedStr == newParsed.Build() {
hasMatch = true
break
}
exists = true
break
}
if !exists {
if !hasMatch {
return validation.NewError(
"validation_missing_system_index",
fmt.Sprintf("Missing system index %q.", oldParsed.IndexName),
).SetParams(map[string]any{"name": oldParsed.IndexName})
"validation_invalid_unique_system_field_index",
"Unique index definition on system fields ({{.fieldName}}) is invalid or missing.",
).SetParams(map[string]any{"fieldName": f.GetName()})
}
continue OLD_INDEXES_LOOP
@@ -630,15 +666,15 @@ func (cv *collectionValidator) checkIndexes(value any) error {
// check for required indexes
//
// note: this is in case the indexes were removed manually when creating/importing new auth collections
// and technically is not necessary since on app.Save the missing index will be reinserted by the system collection hook
// and technically it is not necessary because on app.Save() the missing indexes will be reinserted by the system collection hook
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 "`+name+`".`,
)
`Missing required unique index for field "{{.fieldName}}".`,
).SetParams(map[string]any{"fieldName": name})
}
}
}
+100 -4
View File
@@ -434,6 +434,18 @@ func TestCollectionValidate(t *testing.T) {
},
expectedErrors: []string{"indexes"},
},
{
name: "duplicated index definitions",
collection: func(app core.App) (*core.Collection, error) {
c, _ := app.FindCollectionByNameOrId("demo1")
c.Indexes = []string{
"create index idx_test_demo1 on demo1 (id)",
"create index idx_test_demo2 on demo1 (id)",
}
return c, nil
},
expectedErrors: []string{"indexes"},
},
{
name: "try to add index to a view collection",
collection: func(app core.App) (*core.Collection, error) {
@@ -538,7 +550,7 @@ func TestCollectionValidate(t *testing.T) {
expectedErrors: []string{"indexes"},
},
{
name: "changing index on system field",
name: "changing partial constraint of existing index on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
@@ -559,7 +571,91 @@ func TestCollectionValidate(t *testing.T) {
// replace the index with a partial one
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_unique_demo2_title", true, "title", "1 = 1")
demo2.AddIndex("idx_new_demo2_title", true, "title", "1 = 1")
return demo2, nil
},
expectedErrors: []string{},
},
{
name: "changing column sort and collate of existing index on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// mark the title field as system
demo2.Fields.GetByName("title").SetSystem(true)
if err = app.Save(demo2); err != nil {
return nil, err
}
// refresh
demo2, err = app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// replace the index with a new one for the same column but with collate and sort
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_new_demo2_title", true, "title COLLATE test ASC", "")
return demo2, nil
},
expectedErrors: []string{},
},
{
name: "adding new column to index on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// mark the title field as system
demo2.Fields.GetByName("title").SetSystem(true)
if err = app.Save(demo2); err != nil {
return nil, err
}
// refresh
demo2, err = app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// replace the index with a non-unique one
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_new_title", false, "title, id", "")
return demo2, nil
},
expectedErrors: []string{"indexes"},
},
{
name: "changing index type on system field",
collection: func(app core.App) (*core.Collection, error) {
demo2, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// mark the title field as system
demo2.Fields.GetByName("title").SetSystem(true)
if err = app.Save(demo2); err != nil {
return nil, err
}
// refresh
demo2, err = app.FindCollectionByNameOrId("demo2")
if err != nil {
return nil, err
}
// replace the index with a non-unique one (partial constraints are ignored)
demo2.RemoveIndex("idx_unique_demo2_title")
demo2.AddIndex("idx_new_title", false, "title", "1=1")
return demo2, nil
},
@@ -608,7 +704,7 @@ func TestCollectionValidate(t *testing.T) {
collection: func(app core.App) (*core.Collection, error) {
c := core.NewBaseCollection("new_auth")
c.Fields = core.NewFieldsList(
&core.TextField{Name: "id", PrimaryKey: true, Required: true},
&core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
)
return c, nil
},
@@ -619,7 +715,7 @@ func TestCollectionValidate(t *testing.T) {
collection: func(app core.App) (*core.Collection, error) {
c := core.NewBaseCollection("new_auth")
c.Fields = core.NewFieldsList(
&core.TextField{Name: "id", PrimaryKey: true, Required: true},
&core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
&core.TextField{Id: "f1", Name: "Test"}, // case-insensitive
&core.BoolField{Id: "f2", Name: "test"},
)
+13 -14
View File
@@ -59,19 +59,19 @@ func GenerateDefaultRandomId() string {
// crc32Checksum generates a stringified crc32 checksum from the provided plain string.
func crc32Checksum(str string) string {
return strconv.Itoa(int(crc32.ChecksumIEEE([]byte(str))))
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
}
// ModelQuery creates a new preconfigured select app.DB() query with preset
// 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
}
@@ -464,7 +463,7 @@ func validateCollectionId(app App, optTypes ...string) validation.RuleFunc {
return validation.NewError(
"validation_invalid_collection_type",
fmt.Sprintf("Invalid collection type - must be %s.", strings.Join(optTypes, ", ")),
).SetParams(map[string]any{"types": optTypes})
)
}
return nil
@@ -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)
}
@@ -1,4 +1,4 @@
//go:build !cgo
//go:build !no_default_driver
package core
@@ -7,7 +7,7 @@ import (
_ "modernc.org/sqlite"
)
func dbConnect(dbPath string) (*dbx.DB, error) {
func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
// Note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
-50
View File
@@ -1,50 +0,0 @@
//go:build cgo
package core
import (
"database/sql"
"github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
)
func init() {
// Registers the sqlite3 driver with a ConnectHook so that we can
// initialize the default PRAGMAs.
//
// Note 1: we don't define the PRAGMA as part of the dsn string
// because not all pragmas are available.
//
// Note 2: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
sql.Register("pb_sqlite3",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
_, err := conn.Exec(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -16000;
`, nil)
return err
},
},
)
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
}
func dbConnect(dbPath string) (*dbx.DB, error) {
db, err := dbx.Open("pb_sqlite3", dbPath)
if err != nil {
return nil, err
}
return db, nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build no_default_driver
package core
import "github.com/pocketbase/dbx"
func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
panic("DBConnect config option must be set when the no_default_driver tag is used!")
}
+10 -8
View File
@@ -46,14 +46,16 @@ func baseLockRetry(op func(attempt int) error, maxRetries int) error {
Retry:
err := op(attempt)
if err != nil &&
attempt <= maxRetries &&
// we are checking the plain error text to handle both cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(getDefaultRetryInterval(attempt))
attempt++
goto Retry
if err != nil && attempt <= maxRetries {
errStr := err.Error()
// we are checking the error against the plain error texts since the codes could vary between drivers
if strings.Contains(errStr, "database is locked") ||
strings.Contains(errStr, "table is locked") {
// wait and retry
time.Sleep(getDefaultRetryInterval(attempt))
attempt++
goto Retry
}
}
return err
+1
View File
@@ -33,6 +33,7 @@ func TestBaseLockRetry(t *testing.T) {
{nil, 3, 1},
{errors.New("test"), 3, 1},
{errors.New("database is locked"), 3, 3},
{errors.New("table is locked"), 3, 3},
}
for i, s := range scenarios {
+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()
+178
View File
@@ -168,16 +168,28 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
})
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
afterCreateHookCalls++
return e.Next()
})
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
afterUpdateHookCalls++
return e.Next()
})
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
afterDeleteHookCalls++
return e.Next()
})
@@ -233,3 +245,169 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
}
}
func TestTransactionFromInnerCreateHook(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.OnRecordCreateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
originalApp := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() {
e.App = originalApp
}()
nextErr := e.Next()
return nextErr
})
})
app.OnRecordAfterCreateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
// perform a db query with the app instance to ensure that it is still valid
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatalf("Failed to perform a db query after tx success: %v", err)
}
return e.Next()
})
collection, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
record := core.NewRecord(collection)
record.Set("title", "test_inner_tx")
if err = app.Save(record); err != nil {
t.Fatalf("Create failed: %v", err)
}
expectedHookCalls := map[string]int{
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
}
for k, total := range expectedHookCalls {
if found, ok := app.EventCalls[k]; !ok || total != found {
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
}
}
}
func TestTransactionFromInnerUpdateHook(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.OnRecordUpdateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
originalApp := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() {
e.App = originalApp
}()
nextErr := e.Next()
return nextErr
})
})
app.OnRecordAfterUpdateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
// perform a db query with the app instance to ensure that it is still valid
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatalf("Failed to perform a db query after tx success: %v", err)
}
return e.Next()
})
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatal(err)
}
if err = app.Save(existingModel); err != nil {
t.Fatalf("Update failed: %v", err)
}
expectedHookCalls := map[string]int{
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
}
for k, total := range expectedHookCalls {
if found, ok := app.EventCalls[k]; !ok || total != found {
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
}
}
}
func TestTransactionFromInnerDeleteHook(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.OnRecordDeleteExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
originalApp := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() {
e.App = originalApp
}()
nextErr := e.Next()
return nextErr
})
})
app.OnRecordAfterDeleteSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
if e.App.IsTransactional() {
t.Fatal("Expected e.App to be non-transactional")
}
// perform a db query with the app instance to ensure that it is still valid
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatalf("Failed to perform a db query after tx success: %v", err)
}
return e.Next()
})
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
if err != nil {
t.Fatal(err)
}
if err = app.Delete(existingModel); err != nil {
t.Fatalf("Delete failed: %v", err)
}
expectedHookCalls := map[string]int{
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
}
for k, total := range expectedHookCalls {
if found, ok := app.EventCalls[k]; !ok || total != found {
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
}
}
}
+4 -2
View File
@@ -82,7 +82,7 @@ func (e *RequestEvent) HasSuperuserAuth() bool {
// RequestInfo parses the current request into RequestInfo instance.
//
// Note that the returned result is cached to avoid copying the request data multiple times
// but the auth state and other common store items are always refreshed in case they were changed my another handler.
// but the auth state and other common store items are always refreshed in case they were changed by another handler.
func (e *RequestEvent) RequestInfo() (*RequestInfo, error) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -154,8 +154,10 @@ const (
RequestInfoContextExpand = "expand"
RequestInfoContextRealtime = "realtime"
RequestInfoContextProtectedFile = "protectedFile"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextBatch = "batch"
RequestInfoContextOAuth2 = "oauth2"
RequestInfoContextOTP = "otp"
RequestInfoContextPasswordAuth = "password"
)
// RequestInfo defines a HTTP request data struct, usually used
+2
View File
@@ -4,9 +4,11 @@ import (
"net/http"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/hook"
)
type BatchRequestEvent struct {
hook.Event
*RequestEvent
Batch []*InternalRequest

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