Compare commits

...
195 Commits
Author SHA1 Message Date
Gani Georgiev 238eddc4e0 updated go deps 2024-07-23 22:22:10 +03:00
Gani Georgiev 5be32e8651 added empty dir delete test for trailing slash prefixes 2024-07-23 22:09:25 +03:00
Gani Georgiev d2e355e8cb [#5246] improved files delete performance when using the local filesystem 2024-07-23 20:44:04 +03:00
Gani Georgiev 9663411171 updated changelog 2024-07-09 23:18:45 +03:00
Gani Georgiev 01450cde44 normalized internal errors formatting 2024-07-09 22:18:04 +03:00
Gani Georgiev 1f08b70283 updated logs delete trigger frequency and tests 2024-07-09 22:04:39 +03:00
Gani Georgiev b0f8c78022 updated ui/dist 2024-07-09 21:53:16 +03:00
Gani Georgiev f5f92fde58 updated min sidebar width 2024-07-09 21:48:43 +03:00
Gani Georgiev 55f15179cb updated tinymce 2024-07-09 21:39:38 +03:00
Gani Georgiev e76662247b updated ui/dist 2024-07-09 18:43:58 +03:00
Gani Georgiev 4d5188d3fe updated npm deps 2024-07-09 18:43:44 +03:00
Gani Georgiev 8560e90c08 disabled mouse selection when changing the sidebar width 2024-07-09 18:31:34 +03:00
Gani Georgiev 1e8e70c53c fixed logs delete check 2024-07-09 18:30:23 +03:00
Gani Georgiev f9fcea8770 updated changelog 2024-07-06 14:18:29 +03:00
Gani Georgiev 2036287a39 [#5179] added logs delete trigger test and bumped app version 2024-07-06 14:04:06 +03:00
Nehme Roumani 10ac417d96 [#5179] fixed days calculation for triggering old logs deletion 2024-07-06 12:50:21 +03:00
Gani Georgiev f1aa477378 downgrade modernc.org/libc as it needs to match with the modernc.org/sqlite go.mod 2024-07-02 23:00:26 +03:00
Gani Georgiev ae2f8a4758 log cronAdd failures with error log level 2024-07-02 22:54:07 +03:00
Gani Georgiev c7f758a4dd normalized wrapped errors casing 2024-07-02 22:47:25 +03:00
Gani Georgiev 29b75baafb updated go deps 2024-07-02 22:44:04 +03:00
Gani Georgiev a8e9c97600 bumped the min Go version in the GitHub release action 2024-07-02 12:59:13 +03:00
Gani Georgiev 679751d4a7 updated ui/dist 2024-07-02 00:02:39 +03:00
Gani Georgiev c79ef4a62e updated goja 2024-07-01 21:53:32 +03:00
Gani Georgiev 3a80d44dda manually unset the verified state on drysubmit 2024-07-01 21:43:27 +03:00
Gani Georgiev 5b58a78e64 [#5157] added tests.TestMailer mutex 2024-07-01 20:54:05 +03:00
Gani Georgiev bc1bcac5a1 updated changelog with the reporter GitHub username handle 2024-06-18 22:31:06 +03:00
Gani Georgiev 76b43ebfa4 updated changelog 2024-06-18 18:00:26 +03:00
Gani Georgiev 9fae8c9e72 updated go deps 2024-06-18 16:27:58 +03:00
Gani Georgiev 58ace5d5e7 updated the rules when linking OAuth2 by email 2024-06-18 16:26:32 +03:00
Gani Georgiev af9cf33553 [#5074] redirect with 303 in case of a POST OAuth2 callback 2024-06-18 12:10:12 +03:00
Gani Georgiev d417b86fc0 added POST OAuth2 redirect test 2024-06-14 11:50:56 +03:00
Gani Georgiev 9d847678df added support for OAuth2 post redirect 2024-06-14 11:42:48 +03:00
Gani Georgiev 970b00011f updated go deps and bumped app version 2024-06-03 08:36:28 +03:00
Gani Georgiev 1682db5c72 updated ui/dist and go deps 2024-05-11 09:08:29 +03:00
Gani Georgiev 5acaa0a55c [#4865] fixed Firefox calendar picker grid layout 2024-05-04 13:48:46 +03:00
Gani Georgiev 8a75a9ab04 Merge branch 'master' into develop 2024-05-04 11:25:46 +03:00
Gani Georgiev c410f34089 updated go deps 2024-05-03 20:07:13 +03:00
Gani Georgiev 52a0a87cb2 Merge branch 'master' into develop 2024-05-03 19:59:09 +03:00
Gani Georgiev 2aeb37dcd0 [#4857] load the full record in the relation picker edit panel 2024-05-03 19:58:52 +03:00
Gani Georgiev 3a18121939 Merge branch 'master' into develop 2024-04-25 11:09:08 +03:00
Gani Georgiev 7a9dae7bdd updated changelog 2024-04-25 11:06:48 +03:00
Gani Georgiev dd7b06c00f Merge branch 'master' into develop 2024-04-25 10:52:54 +03:00
Gani Georgiev 950f796cbc added temp collections cache 2024-04-25 10:14:59 +03:00
Gani Georgiev 7675d2e07b Merge branch 'master' into develop 2024-04-24 23:34:44 +03:00
Gani Georgiev 2b82c36bdd updated test cases 2024-04-24 23:23:24 +03:00
Gani Georgiev 3df868f72a added extra extension length normalization 2024-04-24 23:20:47 +03:00
Gani Georgiev 4902b72247 Merge branch 'master' into develop 2024-04-24 22:12:31 +03:00
Gani Georgiev e7ebbd1343 updated go deps 2024-04-24 22:12:09 +03:00
Gani Georgiev ece62ebdf5 [#4824] updated the uploaded filename normalization to take double extensions in consideration 2024-04-24 22:00:18 +03:00
Gani Georgiev 0ea26d91e1 rollback to goreleaser-action v3 2024-04-15 09:44:02 +03:00
Gani Georgiev 7628dc5634 bumped goreleaser action version 2024-04-15 09:37:56 +03:00
Gani Georgiev 4cfabc61e6 updated changelog and ui/dist 2024-04-15 09:20:31 +03:00
Gani Georgiev 34a25f640b updated go deps 2024-04-13 16:19:33 +03:00
Gani Georgiev 4ac0954546 fixed zeroValue isArray check and bumped app version 2024-04-13 16:18:52 +03:00
Gani Georgiev 4745bb4286 fixed zeroValue isArray check 2024-04-13 16:16:21 +03:00
Gani Georgiev 7734d63e51 [#4737] fixed OAuth2 clear btn action 2024-04-13 15:44:20 +03:00
Gani Georgiev 4286812a09 updated go deps 2024-04-06 11:52:44 +03:00
Gani Georgiev 8264ead4de updated changelog 2024-04-05 23:15:41 +03:00
Gani Georgiev 4dc8a10af5 added aria-expanded to the dropdown triggers 2024-04-05 23:15:11 +03:00
Gani Georgiev a9d468a863 updated ui/dist 2024-04-05 20:47:28 +03:00
Gani Georgiev ebc1ed6598 replaced btn mail template outline with border for compatability 2024-04-05 20:46:15 +03:00
Gani Georgiev c951d4bc94 updated ui/dist 2024-04-05 20:35:02 +03:00
Gani Georgiev bee8e0826a [#4707] added constrasting border to the default email template btn style 2024-04-05 20:33:56 +03:00
Gani Georgiev d5fc74d973 updated go deps 2024-04-05 20:20:03 +03:00
Gani Georgiev 63bcffb223 [#4704] fixed '~' autowildcard wrapping when the string has escaped % character 2024-04-05 20:14:28 +03:00
Gani Georgiev ac76166cb2 updated changelog 2024-03-29 21:30:42 +02:00
Gani Georgiev 37dd9c8645 vendored and trimmed the s3blob driver and updated dependencies 2024-03-29 21:19:26 +02:00
Gani Georgiev 9eb3ff5833 updated ui/dist 2024-03-28 13:33:02 +02:00
Marcel van Remmerden 9090979a8d [#4650] updated GitLab logo 2024-03-28 13:29:07 +02:00
Gani Georgiev 7ce2545b8b updated go deps 2024-03-23 13:15:57 +02:00
Gani Georgiev 31f2ba89e8 added aria-hidden attr and bumped app version 2024-03-23 13:12:46 +02:00
Gani Georgiev 0122d4f527 [#4607] fixed the keyboard-accebility of the Admin UI dropdowns 2024-03-22 20:07:01 +02:00
Gani Georgiev b596bbdc3e updated go deps 2024-03-21 10:22:32 +02:00
Gani Georgiev 04927178e5 updated backup restore message 2024-03-21 10:18:00 +02:00
Gani Georgiev 98ba003921 added done channel for the cron ticker 2024-03-20 23:55:32 +02:00
Gani Georgiev 309c4fe6fe call TestApp.ResetBootstrap as finalizer of the test OnTerminate hook 2024-03-20 23:52:26 +02:00
Gani Georgiev 03cec9a5ac [#4600] autorun migrations for the test app and call the OnTerminate hook on TestApp.Cleanup 2024-03-20 22:47:16 +02:00
Gani Georgiev 48153d4542 updated restore backup warning message and changed archive.Extract to ignore irregular files 2024-03-17 15:43:27 +02:00
Gani Georgiev be40803d31 updated security.Encrypt and security.Decrypt docs 2024-03-17 15:42:40 +02:00
Gani Georgiev a5eff395b4 [#4566] fixed JSVM routerUse() example 2024-03-15 11:45:46 +02:00
Gani Georgiev 20653ef786 bumped app version 2024-03-12 23:58:18 +02:00
Gani Georgiev 0f1b73a4f5 [#4544] implemented JSVM FormData and added support for $http.send multipart/form-data requests 2024-03-12 21:35:29 +02:00
Gani Georgiev adab0da179 [#4510] fixed godoc typos 2024-03-07 11:53:54 +02:00
Gani Georgiev e5e2519f88 [#4505] removed redundant CodeBlock component styles 2024-03-06 17:47:41 +02:00
Gani Georgiev 0afc380a11 bumped app version 2024-03-06 16:47:47 +02:00
Gani Georgiev 3551dea44a [#4500] added the field name as part of the @request.data.* relations join 2024-03-06 15:45:25 +02:00
Gani Georgiev eff09852a4 updated GitHub release action min Go version 2024-03-06 11:30:02 +02:00
Gani Georgiev 90c313cf09 updated go deps 2024-03-06 11:20:32 +02:00
Gani Georgiev 5574fe39ce updated go deps 2024-03-06 11:17:56 +02:00
Gani Georgiev 6695aba758 [#4498] fixed OnAfterApiError nil error reference 2024-03-06 11:06:39 +02:00
Gani Georgiev 1eeacf0204 [#4492] fixed admin dropdown z-index on Safari 2024-03-05 19:44:20 +02:00
Gani Georgiev 4a1736a785 restored nullifyMissingField checks 2024-03-03 00:13:48 +02:00
Gani Georgiev 186d2ed328 bumped app version 2024-03-02 18:17:03 +02:00
Gani Georgiev 35d7b5f056 updated changelog 2024-03-01 17:09:39 +02:00
Gani Georgiev bb410e7e0d [#4462] fixed Admin UI record and collection panels not reinitializing properly on browser back/forward navigation 2024-03-01 17:00:26 +02:00
Gani Georgiev 9babca5f77 [#4448] added error checks to the autogenerated Go migrations 2024-02-29 04:17:59 +02:00
Gani Georgiev b845d3dbea [#4437] initialize RecordAuthWithOAuth2Event.IsNewRecord for the OnRecordBeforeAuthWithOAuth2Request hook 2024-02-27 12:14:02 +02:00
Gani Georgiev 39d24ba897 Merge branch 'master' into develop 2024-02-26 20:03:45 +02:00
Gani Georgiev 631957fa32 =fixed changelog typo and added PR link 2024-02-26 19:57:53 +02:00
Gani Georgiev f414e70ffa Merge branch 'develop' 2024-02-26 19:10:55 +02:00
Gani Georgiev d084800c45 updated go deps and bumped ui version 2024-02-26 19:05:20 +02:00
Gani Georgiev f1a6c19309 fixed logs printer dev tests 2024-02-26 16:39:35 +02:00
Gani Georgiev 53ee5212bc [#4431] always refresh the app settings before loading the backup cron job 2024-02-26 15:01:49 +02:00
Gani Georgiev 548fce20b5 added back-relation expand limit 2024-02-25 21:06:43 +02:00
Gani Georgiev 1014c92d86 sort exported collections by type and name 2024-02-25 21:06:14 +02:00
Gani Georgiev 88c56cd539 added :each support for file and relation fields 2024-02-25 12:19:19 +02:00
Gani Georgiev a8b363ed76 normalized collections export sidebar padding and reduced the waiting time for the cron test 2024-02-24 21:35:03 +02:00
Gani Georgiev 6132fb4a03 updated collections export styles 2024-02-24 17:18:06 +02:00
Gani Georgiev 20fba0f686 moved filter autocomplete to worker 2024-02-24 13:46:16 +02:00
Gani Georgievandalxjsn 4f46222de9 [#4393] added Planning Center OAuth2 provider
Co-authored-by: alxjsn <alxjsn@sameorigin.org>
2024-02-24 08:46:22 +02:00
Gani Georgiev 4fba93e834 regenerated jsvm types and added locks for the startTimer 2024-02-21 22:42:01 +02:00
Gani Georgiev 5a715cc60a [#4394] reschedule the first cron tick to start at 00 second 2024-02-21 19:49:52 +02:00
Gani Georgiev f2ed186540 added autocomplete for the back relation keys 2024-02-19 23:13:04 +02:00
Gani Georgiev 4937acb3e2 added back relation filter reference support 2024-02-19 16:55:34 +02:00
Gani Georgiev 4743c1ce72 updated jsvm types and changelog 2024-02-17 17:14:46 +02:00
Gani Georgiev a11abef84b added @request.context field 2024-02-17 15:01:09 +02:00
Gani Georgiev 6aaf98215d hide the merge collections import btn if no schema is specified 2024-02-12 12:33:31 +02:00
Gani Georgiev 2662d875b9 removed unnecessary concat 2024-02-12 11:40:04 +02:00
Gani Georgiev 959c6b6d6c [#3403] added option to import/export a subset of collections 2024-02-12 11:38:22 +02:00
Gani Georgiev d4a2f05075 added presentable file field fallback 2024-02-11 22:31:10 +02:00
Gani Georgiev 4c14c6cccf synced with master 2024-02-11 11:08:47 +02:00
Gani Georgiev aaa6e971a3 fixed changelog typo 2024-02-11 09:37:27 +02:00
Gani Georgiev 27b6d0c505 updated jstypes and ui/dist 2024-02-10 23:28:23 +02:00
Gani Georgiev a46815ed69 merged with master 2024-02-10 15:41:39 +02:00
Gani Georgiev a5fdbeae79 manually clear all TinyMCE events on editor removal 2024-02-10 15:06:49 +02:00
Gani Georgiev 8599754e45 sync with master 2024-02-10 11:16:23 +02:00
Gani Georgiev 71141dde69 aligned healthCheckResponse struct fields 2024-02-10 11:04:59 +02:00
Gani Georgiev 388f61aed6 [#4310] allow HEAD requests to the health endpoint 2024-02-10 10:59:39 +02:00
Gani Georgiev c32f272123 [#4322] disable the JS required validations for disabled OIDC providers 2024-02-09 22:17:26 +02:00
Gani Georgiev 81ef6f1127 Merge branch 'master' into develop 2024-02-08 00:29:41 +02:00
Gani Georgiev 8f8a7c3268 fixed readme typo 2024-02-08 00:29:16 +02:00
Gani Georgiev 1b89aabf14 updated github actions 2024-02-07 21:45:55 +02:00
Gani Georgiev 5f1b2fda74 updated github action node version 2024-02-07 21:22:16 +02:00
Gani Georgiev 9f1c1c2e33 updated go deps and the min github action go version 2024-02-07 21:19:45 +02:00
Gani Georgiev 7ef118581b use the email field const 2024-02-07 21:17:39 +02:00
Gani Georgiev b7447f3e27 synced with master 2024-02-07 21:13:35 +02:00
Gani Georgiev 368af1f0fc updated the readme 2024-02-07 21:04:20 +02:00
Gani Georgiev 41aa9b189c updated changelog 2024-02-07 20:15:17 +02:00
Gani Georgiev ed9cc2f33c updated changelog 2024-02-07 20:04:20 +02:00
Gani Georgiev bada2338f7 [#2173] fixed request.auth.* initialization which caused the current authenticated user email to not being returned in the authRefresh() calls 2024-02-07 19:51:09 +02:00
Gani Georgiev 722a74994f fixed the error reporting of admin update/delete commands 2024-02-06 13:55:08 +02:00
Gani Georgiev 442b286b1d updated changelog 2024-02-05 23:10:01 +02:00
Gani Georgiev 5105612a45 renamed gcp middleware file and updated go deps 2024-02-05 17:59:31 +02:00
Gani Georgiev b9029010d9 upgraded to aws-sdk-go-v2 and added a special middleware for GCP 2024-02-05 17:26:39 +02:00
Gani Georgiev 03a3f9876e sync Admin UI collection changes across browser tabs 2024-02-03 15:39:09 +02:00
Gani Georgiev 49adba6947 added jsvm.Config.OnInit optional field 2024-02-03 13:07:37 +02:00
Gani Georgiev ef965aafbb removed unused tinymce assets 2024-02-03 11:35:43 +02:00
Gani Georgiev fa8e3d83b5 updated npm deps 2024-02-02 17:32:42 +02:00
Gani Georgiev 8402938191 fixed Admin UI vertical image preview scroll 2024-02-02 15:10:33 +02:00
Gani Georgiev 8a0eed22fa update ghupdate to use the config executable name when excluding the update note from the release notes 2024-02-02 12:48:37 +02:00
Gani Georgiev 3b6fcf265a fixed RecordUpsert.RemoveFiles godoc example 2024-02-02 09:21:41 +02:00
Gani Georgiev fb78a39161 updated readme and the thumbGenSem limit 2024-01-31 11:08:40 +02:00
Gani Georgiev 9436efb7fd fixed hideControls store reactivity check 2024-01-24 17:38:22 +02:00
Gani Georgiev 05556e7cbc updated changelog and go deps 2024-01-24 11:18:12 +02:00
Gani Georgiev 2862119c1f updated serve command error reporting 2024-01-24 11:06:49 +02:00
Gani Georgiev eaf121ead7 updated ui/dist 2024-01-23 21:28:13 +02:00
Gani Georgiev aabe820e35 fixed typos and some linter suggestions 2024-01-23 20:56:14 +02:00
Gani Georgiev 80d65a198b optimized multiple records cascade delete query 2024-01-23 20:22:51 +02:00
Gani Georgiev 3013e0299a added helper admin cmd error message in case the migrations are not initialized yet 2024-01-23 20:13:30 +02:00
Gani Georgiev 6fd2e7ab0f updated min Go and Node.js verion in CONTRIBUTING.md 2024-01-22 16:54:12 +02:00
Gani Georgiev a44a73a17c fixed unverified typos 2024-01-22 08:02:48 +02:00
Gani Georgiev bf30af393e include 0 in the auto numeric suffix field name 2024-01-21 20:57:48 +02:00
Gani Georgiev f0410a7625 [#4033] added option to duplicate fields 2024-01-21 20:22:56 +02:00
Gani Georgiev ba56623245 exported .gzip() and .bodyLimit(bytes) JSVM middlewares 2024-01-21 17:13:22 +02:00
Gani Georgiev 702b4aa1c2 Merge branch 'master' into develop 2024-01-21 15:21:22 +02:00
Gani Georgiev 3f7db19fdd remove funding.yaml 2024-01-21 12:29:54 +02:00
Gani Georgiev 9855397a22 replaced the default binder with rest.MultiBinder 2024-01-20 15:03:45 +02:00
Gani Georgiev d9b219d64f [#4192] take collection minPasswordLength in consideration for the user password generator btn 2024-01-20 13:18:00 +02:00
Gani Georgiev c642a860ca rename local const redirect path vars for consistency 2024-01-20 13:16:06 +02:00
Gani Georgiev b2b792b763 [#4177] added graceful OAuth2 redirect error handling 2024-01-19 19:15:01 +02:00
Willow (GHOST) fc18e69183 [#4175] update patreon logo 2024-01-18 18:13:46 +02:00
Gani Georgiev fa65038fc1 synced with master 2024-01-16 13:14:07 +02:00
Gani Georgiev c9bc2f07aa added EmailTemplate.Hidden field 2024-01-16 11:38:09 +02:00
Gani Georgiev 28fc186f5c added support for loading a serialized json payload as part of multipart/form-data request 2024-01-14 22:20:46 +02:00
Gani Georgiev cdb539dcc8 updated changelog 2024-01-13 18:02:49 +02:00
Gani Georgiev af7c6d8d9b [#4066] mark user as verified on confirm password reset 2024-01-13 17:52:41 +02:00
Gani Georgiev cd2fc536ca updated Prism.js bundle 2024-01-13 16:26:59 +02:00
Gani Georgiev 2a28f6ff33 [#4106] added custom Prism.js bundle and registered new TinyMCE codesample languages 2024-01-13 16:13:32 +02:00
Gani Georgiev 036c0da05f reduce slightly the min row table height 2024-01-13 14:55:59 +02:00
Gani Georgiev d795a6671b updated go.sum 2024-01-13 13:23:21 +02:00
Gani Georgiev 8cff94f27c synced with master 2024-01-13 13:23:08 +02:00
Gani Georgiev 6155e6426a ghupdate messages update 2024-01-13 11:21:55 +02:00
Gani Georgiev 0f95a11fc1 Merge branch 'master' into develop 2024-01-05 20:52:27 +02:00
Gani Georgiev b033109654 synced with master 2024-01-04 21:26:55 +02:00
Gani Georgiev 3b7d0e84f6 synced with master 2024-01-03 17:00:24 +02:00
Gani Georgiev d123e19e61 synced with master 2024-01-03 12:46:49 +02:00
Gani Georgiev 97a8409a65 fixed sleep example typo and synced with master 2023-12-30 12:06:07 +02:00
Gani Georgiev 422eb30797 synced with master 2023-12-29 23:31:54 +02:00
Gani Georgiev 705d7f48e7 synced with master 2023-12-29 10:00:06 +02:00
Gani Georgiev e73b3a32d2 synced with master 2023-12-27 10:50:48 +02:00
Gani Georgiev 370862fa2e updated AppleClientSecretCreate struct comment 2023-12-26 20:50:35 +02:00
Gani Georgiev 6b3780c630 [#4035] replaced JWT token with just JWT 2023-12-26 19:57:38 +02:00
Gani Georgiev c807f66c59 synced with master 2023-12-24 11:23:50 +02:00
Gani Georgiev 5f5f9ca426 reorder loading=lazy before src per the svelte docs 2023-12-18 07:47:17 +02:00
Gani Georgievandaabajyan 4e91be6d74 [#3948] added Bitbucket OAuth2 provider
Co-authored-by: aabajyan <arsen.abajyan@pm.me>
2023-12-17 15:47:17 +02:00
309 changed files with 11353 additions and 7178 deletions
-13
View File
@@ -1,13 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: pocketbase
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: ['https://www.paypal.com/donate/?hosted_button_id=4DVXNL4B8WT98']
+5 -5
View File
@@ -9,19 +9,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20.8.1
node-version: 20.11.0
- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: '>=1.21.6'
go-version: '>=1.22.5'
# 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.
+278
View File
@@ -1,3 +1,281 @@
## v0.22.18
- Improved files delete performance when using the local filesystem by adding a trailing slash to the `DeletePrefix` call to ensure that the list iterator will start "walking" from the prefix directory and not from its parent ([#5246](https://github.com/pocketbase/pocketbase/discussions/5246)).
- Updated Go deps.
## v0.22.17
- Updated the `editor` field to use the latest TinyMCE 6.8.4 and enabled `convert_unsafe_embeds:true` by default per the security advisories.
_The Admin UI shouldn't be affected by the older TinyMCE because we don't use directly the vulnerable options/plugins and we have a default CSP, but it is recommended to update even just for silencing the CI/CD warnings._
- Disabled mouse selection when changing the sidebar width.
_This should also fix the reported Firefox issue when the sidebar width "resets" on mouse release out of the page window._
- Other minor improvements (updated the logs delete check and tests, normalized internal errors formatting, updated Go deps, etc.)
## v0.22.16
- Fixed the days calculation for triggering old logs deletion ([#5179](https://github.com/pocketbase/pocketbase/pull/5179); thanks @nehmeroumani).
_Note that the previous versions correctly delete only the logs older than the configured setting but due to the typo the delete query is invoked unnecessary on each logs batch write._
## v0.22.15
- Added mutex to `tests.TestMailer()` to minimize tests data race warnings ([#5157](https://github.com/pocketbase/pocketbase/issues/5157)).
- Updated goja and the other Go dependencies.
- Bumped the min Go version in the GitHub release action to Go 1.22.5 since it comes with [`net/http` security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.5).
## v0.22.14
- Added OAuth2 POST redirect support (in case of `response_mode=form_post`) to allow specifying scopes for the Apple OAuth2 integration.
Note 1: If you are using the "Manual code exchange" flow with Apple (aka. `authWithOAuth2Code()`), you need to either update your custom
redirect handler to accept POST requests OR if you want to keep the old behavior and don't need the Apple user's email - replace in the Apple authorization url `response_mode=form_post` back to `response_mode=query`.
Note 2: Existing users that have already logged in with Apple may need to revoke their access in order to see the email sharing options as shown in [this screenshot](https://github.com/pocketbase/pocketbase/discussions/5074#discussioncomment-9801855).
If you want to force the new consent screen you could register a new Apple OAuth2 app.
- ⚠️ Fixed a security vulnerability related to the OAuth2 email autolinking (thanks to @dalurness for reporting it).
Just to be safe I've also published a [GitHub security advisory](https://github.com/pocketbase/pocketbase/security/advisories/GHSA-m93w-4fxv-r35v) (_may take some time to show up in the related security databases_).
In order to be exploited you must have **both** OAuth2 and Password auth methods enabled.
A possible attack scenario could be:
- a malicious actor register with the targeted user's email (it is unverified)
- at some later point in time the targeted user stumble on your app and decides to sign-up with OAuth2 (_this step could be also initiated by the attacker by sending an invite email to the targeted user_)
- on successful OAuth2 auth we search for an existing PocketBase user matching with the OAuth2 user's email and associate them
- because we haven't changed the password of the existing PocketBase user during the linking, the malicious actor has access to the targeted user account and will be able to login with the initially created email/password
To prevent this for happening we now reset the password for this specific case if the previously created user wasn't verified (an exception to this is if the linking is explicit/manual, aka. when you send `Authorization:TOKEN` with the OAuth2 auth call).
Additionally to warn users we now send an email alert in case the user has logged in with password but has at least one OAuth2 account linked. It looks something like:
_Hello,
Just to let you know that someone has logged in to your Acme account using a password while you already have OAuth2 GitLab auth linked.
If you have recently signed in with a password, you may disregard this email.
**If you don't recognize the above action, you should immediately change your Acme account password.**
Thanks,
Acme team_
The flow will be further improved with the [ongoing refactoring](https://github.com/pocketbase/pocketbase/discussions/4355) and we will start sending emails for "unrecognized device" logins (OTP and MFA is already implemented and will be available with the next v0.23.0 release in the near future).
## v0.22.13
- Fixed rules inconsistency for text literals when inside parenthesis ([#5017](https://github.com/pocketbase/pocketbase/issues/5017)).
- Updated Go deps.
## v0.22.12
- Fixed calendar picker grid layout misalignment on Firefox ([#4865](https://github.com/pocketbase/pocketbase/issues/4865)).
- Updated Go deps and bumped the min Go version in the GitHub release action to Go 1.22.3 since it comes with [some minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.3).
## v0.22.11
- Load the full record in the relation picker edit panel ([#4857](https://github.com/pocketbase/pocketbase/issues/4857)).
## v0.22.10
- Updated the uploaded filename normalization to take double extensions in consideration ([#4824](https://github.com/pocketbase/pocketbase/issues/4824))
- Added Collection models cache to help speed up the common List and View requests execution with ~25%.
_This was extracted from the ongoing work on [#4355](https://github.com/pocketbase/pocketbase/discussions/4355) and there are many other small optimizations already implemented but they will have to wait for the refactoring to be finalized._
## v0.22.9
- Fixed Admin UI OAuth2 "Clear all fields" btn action to properly unset all form fields ([#4737](https://github.com/pocketbase/pocketbase/issues/4737)).
## v0.22.8
- Fixed '~' auto wildcard wrapping when the param has escaped `%` character ([#4704](https://github.com/pocketbase/pocketbase/discussions/4704)).
- Other minor UI improvements (added `aria-expanded=true/false` to the dropdown triggers, added contrasting border around the default mail template btn style, etc.).
- Updated Go deps and bumped the min Go version in the GitHub release action to Go 1.22.2 since it comes with [some `net/http` security and bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.2).
## v0.22.7
- Replaced the default `s3blob` driver with a trimmed vendored version to reduce the binary size with ~10MB.
_It can be further reduced with another ~10MB once we replace entirely the `aws-sdk-go-v2` dependency but I stumbled on some edge cases related to the headers signing and for now is on hold._
- Other minor improvements (updated GitLab OAuth2 provider logo [#4650](https://github.com/pocketbase/pocketbase/pull/4650), normalized error messages, updated npm dependencies, etc.)
## v0.22.6
- Admin UI accessibility improvements:
- Fixed the dropdowns tab/enter/space keyboard navigation ([#4607](https://github.com/pocketbase/pocketbase/issues/4607)).
- Added `role`, `aria-label`, `aria-hidden` attributes to some of the elements in attempt to better assist screen readers.
## v0.22.5
- Minor test helpers fixes ([#4600](https://github.com/pocketbase/pocketbase/issues/4600)):
- Call the `OnTerminate` hook on `TestApp.Cleanup()`.
- Automatically run the DB migrations on initializing the test app with `tests.NewTestApp()`.
- Added more elaborate warning message when restoring a backup explaining how the operation works.
- Skip irregular files (symbolic links, sockets, etc.) when restoring a backup zip from the Admin UI or calling `archive.Extract(src, dst)` because they come with too many edge cases and ambiguities.
<details>
<summary><b><i>More details</i></b></summary>
This was initially reported as security issue (_thanks Harvey Spec_) but in the PocketBase context it is not something that can be exploited without an admin intervention and since the general expectations are that the PocketBase admins can do anything and they are the one who manage their server, this should be treated with the same diligence when using `scp`/`rsync`/`rclone`/etc. with untrusted file sources.
It is not possible (_or at least I'm not aware how to do that easily_) to perform virus/malicious content scanning on the uploaded backup archive files and some caution is always required when using the Admin UI or running shell commands, hence the backup-restore warning text.
**Or in other words, if someone sends you a file and tell you to upload it to your server (either as backup zip or manually via scp) obviously you shouldn't do that unless you really trust them.**
PocketBase is like any other regular application that you run on your server and there is no builtin "sandbox" for what the PocketBase process can execute. This is left to the developers to restrict on application or OS level depending on their needs. If you are self-hosting PocketBase you usually don't have to do that, but if you are offering PocketBase as a service and allow strangers to run their own PocketBase instances on your server then you'll need to implement the isolation mechanisms on your own.
</details>
## v0.22.4
- Removed conflicting styles causing the detailed codeblock log data preview to not visualize properly ([#4505](https://github.com/pocketbase/pocketbase/pull/4505)).
- Minor JSVM improvements:
- Added `$filesystem.fileFromUrl(url, optSecTimeout)` helper.
- Implemented the `FormData` interface and added support for sending `multipart/form-data` requests with `$http.send()` ([#4544](https://github.com/pocketbase/pocketbase/discussions/4544)).
## v0.22.3
- Fixed the z-index of the current admin dropdown on Safari ([#4492](https://github.com/pocketbase/pocketbase/issues/4492)).
- Fixed `OnAfterApiError` debug log `nil` error reference ([#4498](https://github.com/pocketbase/pocketbase/issues/4498)).
- Added the field name as part of the `@request.data.someRelField.*` join to handle the case when a collection has 2 or more relation fields pointing to the same place ([#4500](https://github.com/pocketbase/pocketbase/issues/4500)).
- Updated Go deps and bumped the min Go version in the GitHub release action to Go 1.22.1 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.1).
## v0.22.2
- Fixed a small regression introduced with v0.22.0 that was causing some missing unknown fields to always return an error instead of applying the specific `nullifyMisingField` resolver option to the query.
## v0.22.1
- Fixed Admin UI record and collection panels not reinitializing properly on browser back/forward navigation ([#4462](https://github.com/pocketbase/pocketbase/issues/4462)).
- Initialize `RecordAuthWithOAuth2Event.IsNewRecord` for the `OnRecordBeforeAuthWithOAuth2Request` hook ([#4437](https://github.com/pocketbase/pocketbase/discussions/4437)).
- Added error checks to the autogenerated Go migrations ([#4448](https://github.com/pocketbase/pocketbase/issues/4448)).
## v0.22.0
- Added Planning Center OAuth2 provider ([#4393](https://github.com/pocketbase/pocketbase/pull/4393); thanks @alxjsn).
- Admin UI improvements:
- Autosync collection changes across multiple open browser tabs.
- Fixed vertical image popup preview scrolling.
- Added options to export a subset of collections.
- Added option to import a subset of collections without deleting the others ([#3403](https://github.com/pocketbase/pocketbase/issues/3403)).
- Added support for back/indirect relation `filter`/`sort` (single and multiple).
The syntax to reference back relation fields is `yourCollection_via_yourRelField.*`.
⚠️ To avoid excessive joins, the nested relations resolver is now limited to max 6 level depth (the same as `expand`).
_Note that in the future there will be also more advanced and granular options to specify a subset of the fields that are filterable/sortable._
- Added support for multiple back/indirect relation `expand` and updated the keys to use the `_via_` reference syntax (`yourCollection_via_yourRelField`).
_To minimize the breaking changes, the old parenthesis reference syntax (`yourCollection(yourRelField)`) will still continue to work but it is soft-deprecated and there will be a console log reminding you to change it to the new one._
- ⚠️ Collections and fields are no longer allowed to have `_via_` in their name to avoid collisions with the back/indirect relation reference syntax.
- Added `jsvm.Config.OnInit` optional config function to allow registering custom Go bindings to the JSVM.
- Added `@request.context` rule field that can be used to apply a different set of constraints based on the API rule execution context.
For example, to disallow user creation by an OAuth2 auth, you could set for the users Create API rule `@request.context != "oauth2"`.
The currently supported `@request.context` values are:
```
default
realtime
protectedFile
oauth2
```
- Adjusted the `cron.Start()` to start the ticker at the `00` second of the cron interval ([#4394](https://github.com/pocketbase/pocketbase/discussions/4394)).
_Note that the cron format has only minute granularity and there is still no guarantee that the scheduled job will be always executed at the `00` second._
- Fixed auto backups cron not reloading properly after app settings change ([#4431](https://github.com/pocketbase/pocketbase/discussions/4431)).
- Upgraded to `aws-sdk-go-v2` and added special handling for GCS to workaround the previous [GCS headers signature issue](https://github.com/pocketbase/pocketbase/issues/2231) that we had with v2.
_This should also fix the SVG/JSON zero response when using Cloudflare R2 ([#4287](https://github.com/pocketbase/pocketbase/issues/4287#issuecomment-1925168142), [#2068](https://github.com/pocketbase/pocketbase/discussions/2068), [#2952](https://github.com/pocketbase/pocketbase/discussions/2952))._
_⚠️ If you are using S3 for uploaded files or backups, please verify that you have a green check in the Admin UI for your S3 configuration (I've tested the new version with GCS, MinIO, Cloudflare R2 and Wasabi)._
- Added `:each` modifier support for `file` and `relation` type fields (_previously it was supported only for `select` type fields_).
- Other minor improvements (updated the `ghupdate` plugin to use the configured executable name when printing to the console, fixed the error reporting of `admin update/delete` commands, etc.).
## v0.21.3
- Ignore the JS required validations for disabled OIDC providers ([#4322](https://github.com/pocketbase/pocketbase/issues/4322)).
- Allow `HEAD` requests to the `/api/health` endpoint ([#4310](https://github.com/pocketbase/pocketbase/issues/4310)).
- Fixed the `editor` field value when visualized inside the View collection preview panel.
- Manually clear all TinyMCE events on editor removal (_workaround for [tinymce#9377](https://github.com/tinymce/tinymce/issues/9377)_).
## v0.21.2
- Fixed `@request.auth.*` initialization side-effect which caused the current authenticated user email to not being returned in the user auth response ([#2173](https://github.com/pocketbase/pocketbase/issues/2173#issuecomment-1932332038)).
_The current authenticated user email should be accessible always no matter of the `emailVisibility` state._
- Fixed `RecordUpsert.RemoveFiles` godoc example.
- Bumped to `NumCPU()+2` the `thumbGenSem` limit as some users reported that it was too restrictive.
## v0.21.1
- Small fix for the Admin UI related to the _Settings > Sync_ menu not being visible even when the "Hide controls" toggle is off.
## v0.21.0
- Added Bitbucket OAuth2 provider ([#3948](https://github.com/pocketbase/pocketbase/pull/3948); thanks @aabajyan).
- Mark user as verified on confirm password reset ([#4066](https://github.com/pocketbase/pocketbase/issues/4066)).
_If the user email has changed after issuing the reset token (eg. updated by an admin), then the `verified` user state remains unchanged._
- Added support for loading a serialized json payload for `multipart/form-data` requests using the special `@jsonPayload` key.
_This is intended to be used primarily by the SDKs to resolve [js-sdk#274](https://github.com/pocketbase/js-sdk/issues/274)._
- Added graceful OAuth2 redirect error handling ([#4177](https://github.com/pocketbase/pocketbase/issues/4177)).
_Previously on redirect error we were returning directly a standard json error response. Now on redirect error we'll redirect to a generic OAuth2 failure screen (similar to the success one) and will attempt to auto close the OAuth2 popup._
_The SDKs are also updated to handle the OAuth2 redirect error and it will be returned as Promise rejection of the `authWithOAuth2()` call._
- Exposed `$apis.gzip()` and `$apis.bodyLimit(bytes)` middlewares to the JSVM.
- Added `TestMailer.SentMessages` field that holds all sent test app emails until cleanup.
- Optimized the cascade delete of records with multiple `relation` fields.
- Updated the `serve` and `admin` commands error reporting.
- Minor Admin UI improvements (reduced the min table row height, added option to duplicate fields, added new TinyMCE codesample plugin languages, hide the collection sync settings when the `Settings.Meta.HideControls` is enabled, etc.)
## v0.20.7
- Fixed the Admin UI auto indexes update when renaming fields with a common prefix ([#4160](https://github.com/pocketbase/pocketbase/issues/4160)).
+2 -2
View File
@@ -10,8 +10,8 @@ This document describes how to prepare a PR for a change in the main repository.
## Prerequisites
- Go 1.19+ (for making changes in the Go code)
- Node 16+ (for making changes in the Admin UI)
- Go 1.21+ (for making changes in the Go code)
- Node 18+ (for making changes in the Admin UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
+57 -50
View File
@@ -7,7 +7,7 @@
<p align="center">
<a href="https://github.com/pocketbase/pocketbase/actions/workflows/release.yaml" target="_blank" rel="noopener"><img src="https://github.com/pocketbase/pocketbase/actions/workflows/release.yaml/badge.svg" alt="build" /></a>
<a href="https://github.com/pocketbase/pocketbase/releases" target="_blank" rel="noopener"><img src="https://img.shields.io/github/release/pocketbase/pocketbase.svg" alt="Latest releases" /></a>
<a href="https://pkg.go.dev/github.com/pocketbase/pocketbase" target="_blank" rel="noopener"><img src="https://godoc.org/github.com/ganigeorgiev/fexpr?status.svg" alt="Go package documentation" /></a>
<a href="https://pkg.go.dev/github.com/pocketbase/pocketbase" target="_blank" rel="noopener"><img src="https://godoc.org/github.com/pocketbase/pocketbase?status.svg" alt="Go package documentation" /></a>
</p>
[PocketBase](https://pocketbase.io) is an open source Go backend, consisting of:
@@ -32,64 +32,76 @@ The easiest way to interact with the API is to use one of the official SDK clien
## Overview
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
### Use as standalone app
You could download the prebuilt executable for your platform from the [Releases page](https://github.com/pocketbase/pocketbase/releases).
Once downloaded, extract the archive and run `./pocketbase serve` in the extracted directory.
The prebuilt executables are based on the [`examples/base/main.go` file](https://github.com/pocketbase/pocketbase/blob/master/examples/base/main.go) and comes with the JS VM plugin enabled by default which allows to extend PocketBase with JavaScript (_for more details please refer to [Extend with JavaScript](https://pocketbase.io/docs/js-overview/)_).
### Use as a Go framework/toolkit
PocketBase is distributed as a regular Go library package which allows you to build
your own custom app specific business logic and still have a single portable executable at the end.
### Installation
Here is a minimal example:
```sh
# go 1.21+
go get github.com/pocketbase/pocketbase
```
0. [Install Go 1.21+](https://go.dev/doc/install) (_if you haven't already_)
### Example
1. Create a new project directory with the following `main.go` file inside it:
```go
package main
```go
package main
import (
"log"
"net/http"
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
func main() {
app := pocketbase.New()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// add new "GET /hello" route to the app router (echo)
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/hello",
Handler: func(c echo.Context) error {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// add new "GET /hello" route to the app router (echo)
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/hello",
Handler: func(c echo.Context) error {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
return nil
})
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
}
```
```
### Running and building
2. To init the dependencies, run `go mod init myapp && go mod tidy`.
Running/building the application is the same as for any other Go program, aka. just `go run` and `go build`.
3. To start the application, run `go run main.go serve`.
**PocketBase embeds SQLite, but doesn't require CGO.**
4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
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.
> [!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:
@@ -100,7 +112,7 @@ To build the minimal standalone executable, like the prebuilt ones in the releas
(_https://go.dev/doc/install/source#environment_)
4. Start the created executable by running `./base serve`.
The supported build targets by the non-cgo driver at the moment are:
Note that the supported build targets by the pure Go SQLite driver at the moment are:
```
darwin amd64
@@ -121,7 +133,7 @@ windows arm64
### Testing
PocketBase comes with mixed bag of unit and integration tests.
To run them, use the default `go test` command:
To run them, use the standard `go test` command:
```sh
go test ./...
@@ -144,7 +156,6 @@ You could help continuing its development by:
- [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us)
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
@@ -153,7 +164,3 @@ PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
> [!NOTE]
> PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
> so all feature requests will be first aligned with what we need for Presentator v3.
+3 -2
View File
@@ -28,6 +28,7 @@ const trailedAdminPath = "/_/"
func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New()
e.Debug = false
e.Binder = &rest.MultiBinder{}
e.JSONSerializer = &rest.Serializer{
FieldsParam: fieldsQueryParam,
}
@@ -82,7 +83,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
logRequest(app, c, apiErr)
if c.Response().Committed {
return // already commited
return // already committed
}
event := new(core.ApiErrorEvent)
@@ -105,7 +106,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
if hookErr == nil {
if err := app.OnAfterApiError().Trigger(event); err != nil {
app.Logger().Debug("OnAfterApiError failure", slog.String("error", hookErr.Error()))
app.Logger().Debug("OnAfterApiError failure", slog.String("error", err.Error()))
}
} else {
app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error()))
+51 -40
View File
@@ -11,6 +11,7 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/spf13/cast"
)
@@ -220,15 +221,24 @@ func TestRemoveTrailingSlashMiddleware(t *testing.T) {
}
}
func TestEagerRequestInfoCache(t *testing.T) {
func TestMultiBinder(t *testing.T) {
t.Parallel()
rawJson := `{"name":"test123"}`
formData, mp, err := tests.MockMultipartData(map[string]string{
rest.MultipartJsonKey: rawJson,
})
if err != nil {
t.Fatal(err)
}
scenarios := []tests.ApiScenario{
{
Name: "custom non-api group route",
Name: "non-api group route",
Method: "POST",
Url: "/custom",
Body: strings.NewReader(`{"name":"test123"}`),
Body: strings.NewReader(rawJson),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: "POST",
@@ -242,11 +252,10 @@ func TestEagerRequestInfoCache(t *testing.T) {
return err
}
// since the unknown method is not eager cache support
// it should fail reading the json body twice
// try to read the body again
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "" {
t.Fatalf("Expected empty request data body, got, %v", r.Data)
if v := cast.ToString(r.Data["name"]); v != "test123" {
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
}
return c.NoContent(200)
@@ -256,41 +265,10 @@ func TestEagerRequestInfoCache(t *testing.T) {
ExpectedStatus: 200,
},
{
Name: "api group route with unsupported eager cache request method",
Name: "api group route",
Method: "GET",
Url: "/api/admins",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// it is not important whether the route handler return an error since
// we just need to ensure that the eagerRequestInfoCache was registered
next(c)
// ensure that the body was read at least once
data := &struct {
Name string `json:"name"`
}{}
c.Bind(data)
// since the unknown method is not eager cache support
// it should fail reading the json body twice
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "" {
t.Fatalf("Expected empty request data body, got, %v", r.Data)
}
return nil
}
})
},
ExpectedStatus: 200,
},
{
Name: "api group route with supported eager cache request method",
Method: "POST",
Url: "/api/admins",
Body: strings.NewReader(`{"name":"test123"}`),
Body: strings.NewReader(rawJson),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -316,6 +294,39 @@ func TestEagerRequestInfoCache(t *testing.T) {
},
ExpectedStatus: 200,
},
{
Name: "custom route with @jsonPayload as multipart body",
Method: "POST",
Url: "/custom",
Body: formData,
RequestHeaders: map[string]string{
"Content-Type": mp.FormDataContentType(),
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: "POST",
Path: "/custom",
Handler: func(c echo.Context) error {
data := &struct {
Name string `json:"name"`
}{}
if err := c.Bind(data); err != nil {
return err
}
// try to read the body again
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "test123" {
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
}
return c.NoContent(200)
},
})
},
ExpectedStatus: 200,
},
}
for _, scenario := range scenarios {
+2 -1
View File
@@ -893,7 +893,8 @@ func TestCollectionUpdate(t *testing.T) {
{"type":"text","name":"password"},
{"type":"text","name":"passwordConfirm"},
{"type":"text","name":"oldPassword"}
]
],
"indexes": []
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+3 -2
View File
@@ -30,7 +30,7 @@ var defaultThumbSizes = []string{"100x100"}
func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{
app: app,
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 1)), // the value is arbitrary chosen and may change in the future
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,
}
@@ -111,7 +111,7 @@ func (api *fileApi) download(c echo.Context) error {
options, ok := fileField.Options.(*schema.FileOptions)
if !ok {
return NewBadRequestError("", errors.New("Failed to load file options."))
return NewBadRequestError("", errors.New("failed to load file options"))
}
// check whether the request is authorized to view the protected file
@@ -122,6 +122,7 @@ func (api *fileApi) download(c echo.Context) error {
// create a copy of the cached request data and adjust it for the current auth model
requestInfo := *RequestInfo(c)
requestInfo.Context = models.RequestInfoContextProtectedFile
requestInfo.Admin = nil
requestInfo.AuthRecord = nil
if adminOrAuthRecord != nil {
+6 -1
View File
@@ -12,6 +12,7 @@ func bindHealthApi(app core.App, rg *echo.Group) {
api := healthApi{app: app}
subGroup := rg.Group("/health")
subGroup.HEAD("", api.healthCheck)
subGroup.GET("", api.healthCheck)
}
@@ -20,8 +21,8 @@ type healthApi struct {
}
type healthCheckResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Code int `json:"code"`
Data struct {
CanBackup bool `json:"canBackup"`
} `json:"data"`
@@ -29,6 +30,10 @@ type healthCheckResponse struct {
// healthCheck returns a 200 OK response if the server is healthy.
func (api *healthApi) healthCheck(c echo.Context) error {
if c.Request().Method == http.MethodHead {
return c.NoContent(http.StatusOK)
}
resp := new(healthCheckResponse)
resp.Code = http.StatusOK
resp.Message = "API is healthy."
+7 -1
View File
@@ -12,7 +12,13 @@ func TestHealthAPI(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "health status returns 200",
Name: "HEAD health status",
Method: http.MethodHead,
Url: "/api/health",
ExpectedStatus: 200,
},
{
Name: "GET health status",
Method: http.MethodGet,
Url: "/api/health",
ExpectedStatus: 200,
+3 -1
View File
@@ -261,7 +261,7 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if param := c.PathParam("collection"); param != "" {
collection, err := app.Dao().FindCollectionByNameOrId(param)
collection, err := core.FindCachedCollectionByNameOrId(app, param)
if err != nil || collection == nil {
return NewNotFoundError("", err)
}
@@ -402,6 +402,8 @@ func realUserIp(r *http.Request, fallbackIp string) string {
return fallbackIp
}
// @todo consider removing as this may no longer be needed due to the custom rest.MultiBinder.
//
// eagerRequestInfoCache ensures that the request data is cached in the request
// context to allow reading for example the json request body data more than once.
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
+2 -1
View File
@@ -371,7 +371,7 @@ type recordData struct {
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
collection := record.Collection()
if collection == nil {
return errors.New("[broadcastRecord] Record collection not set.")
return errors.New("[broadcastRecord] Record collection not set")
}
clients := api.app.SubscriptionsBroker().Clients()
@@ -409,6 +409,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
// mock request data
requestInfo := &models.RequestInfo{
Context: models.RequestInfoContextRealtime,
Method: "GET",
Query: options.Query,
Headers: options.Headers,
+91 -18
View File
@@ -7,19 +7,23 @@ import (
"log/slog"
"net/http"
"sort"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
@@ -30,6 +34,7 @@ func bindRecordAuthApi(app core.App, rg *echo.Group) {
// global oauth2 subscription redirect handler
rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect)
rg.POST("/oauth2-redirect", api.oauth2SubscriptionRedirect) // needed in case of response_mode=form_post
// common collection record related routes
subGroup := rg.Group(
@@ -117,7 +122,7 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
provider, err := auth.NewProviderByName(name)
if err != nil {
api.app.Logger().Debug("Missing or invalid provier name", slog.String("name", name))
api.app.Logger().Debug("Missing or invalid provider name", slog.String("name", name))
continue // skip provider
}
@@ -146,7 +151,7 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
switch name {
case auth.NameApple:
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "form_post"))
}
if provider.PKCE() {
@@ -201,13 +206,14 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
event.HttpContext = c
event.Collection = collection
event.ProviderName = form.Provider
event.IsNewRecord = false
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
event.IsNewRecord = true
// clone the current request data and assign the form create data as its body data
requestInfo := *RequestInfo(c)
requestInfo.Context = models.RequestInfoContextOAuth2
requestInfo.Data = form.CreateData
createRuleFunc := func(q *dbx.SelectQuery) error {
@@ -246,6 +252,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
event.Record = data.Record
event.OAuth2User = data.OAuth2User
event.ProviderClient = data.ProviderClient
event.IsNewRecord = data.Record == nil
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
data.Record = e.Record
@@ -267,6 +274,14 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
}
return api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
// clear the lastLoginAlertSentAt field so that we can enforce password auth notifications
if !e.Record.LastLoginAlertSentAt().IsZero() {
e.Record.Set(schema.FieldNameLastLoginAlertSentAt, "")
if err := api.app.Dao().SaveRecord(e.Record); err != nil {
api.app.Logger().Warn("Failed to reset lastLoginAlertSentAt", "error", err, "recordId", e.Record.Id)
}
}
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
})
})
@@ -302,6 +317,42 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
return NewBadRequestError("Failed to authenticate.", err)
}
// @todo remove after the refactoring
if collection.AuthOptions().AllowOAuth2Auth && e.Record.Email() != "" {
externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(e.Record)
if err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
if len(externalAuths) > 0 {
lastLoginAlert := e.Record.LastLoginAlertSentAt().Time()
// send an email alert if the password auth is after OAuth2 auth (lastLoginAlert will be empty)
// or if it has been ~7 days since the last alert
if lastLoginAlert.IsZero() || time.Now().UTC().Sub(lastLoginAlert).Hours() > 168 {
providerNames := make([]string, len(externalAuths))
for i, ea := range externalAuths {
var name string
if provider, err := auth.NewProviderByName(ea.Provider); err == nil {
name = provider.DisplayName()
}
if name == "" {
name = ea.Provider
}
providerNames[i] = name
}
if err := mails.SendRecordPasswordLoginAlert(api.app, e.Record, providerNames...); err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
e.Record.SetLastLoginAlertSentAt(types.NowDateTime())
if err := api.app.Dao().SaveRecord(e.Record); err != nil {
api.app.Logger().Warn("Failed to update lastLoginAlertSentAt", "error", err, "recordId", e.Record.Id)
}
}
}
}
return api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
@@ -656,29 +707,46 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
// -------------------------------------------------------------------
const oauth2SubscriptionTopic = "@oauth2"
const (
oauth2SubscriptionTopic string = "@oauth2"
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
)
type oauth2RedirectData struct {
State string `form:"state" query:"state" json:"state"`
Code string `form:"code" query:"code" json:"code"`
Error string `form:"error" query:"error" json:"error,omitempty"`
}
func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
state := c.QueryParam("state")
code := c.QueryParam("code")
if code == "" || state == "" {
return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
redirectStatusCode := http.StatusTemporaryRedirect
if c.Request().Method != http.MethodGet {
redirectStatusCode = http.StatusSeeOther
}
client, err := api.app.SubscriptionsBroker().ClientById(state)
data := oauth2RedirectData{}
if err := c.Bind(&data); err != nil {
api.app.Logger().Debug("Failed to read OAuth2 redirect data", "error", err)
return c.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
}
if data.State == "" {
api.app.Logger().Debug("Missing OAuth2 state parameter")
return c.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
}
client, err := api.app.SubscriptionsBroker().ClientById(data.State)
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err)
}
data := map[string]string{
"state": state,
"code": code,
api.app.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State)
return c.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
}
defer client.Unsubscribe(oauth2SubscriptionTopic)
encodedData, err := json.Marshal(data)
if err != nil {
return NewBadRequestError("Failed to marshalize OAuth2 redirect data.", err)
api.app.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
return c.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
}
msg := subscriptions.Message{
@@ -688,5 +756,10 @@ func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
client.Send(msg)
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
if data.Error != "" || data.Code == "" {
api.app.Logger().Debug("Failed OAuth2 redirect due to an error or missing code parameter", "error", data.Error, "clientId", data.State)
return c.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
}
return c.Redirect(redirectStatusCode, oauth2RedirectSuccessPath)
}
+335 -87
View File
@@ -237,6 +237,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
@@ -304,6 +307,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
{
@@ -328,6 +334,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
@@ -644,7 +653,7 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
},
},
{
Name: "valid token and data",
Name: "valid token and data (unverified user)",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
@@ -659,6 +668,132 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
if user.Verified() {
t.Fatalf("Expected the user to be unverified")
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
user, err := app.Dao().FindAuthRecordByToken(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
app.Settings().RecordPasswordResetToken.Secret,
)
if err == nil {
t.Fatalf("Expected the password reset token to be invalidated")
}
user, err = app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be marked as verified")
}
},
},
{
Name: "valid token and data (unverified user with different email from the one in the token)",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345678"
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
if user.Verified() {
t.Fatalf("Expected the user to be unverified")
}
// manually change the email to check whether the verified state will be updated
user.SetEmail("test_update@example.com")
if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil {
t.Fatalf("Failed to update user test email")
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
user, err := app.Dao().FindAuthRecordByToken(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
app.Settings().RecordPasswordResetToken.Secret,
)
if err == nil {
t.Fatalf("Expected the password reset token to be invalidated")
}
user, err = app.Dao().FindAuthRecordByEmail("users", "test_update@example.com")
if err != nil {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
if user.Verified() {
t.Fatalf("Expected the user to remain unverified")
}
},
},
{
Name: "valid token and data (verified user)",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345678"
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
// ensure that the user is already verified
user.SetVerified(true)
if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil {
t.Fatalf("Failed to update user verified state")
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
user, err := app.Dao().FindAuthRecordByToken(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
app.Settings().RecordPasswordResetToken.Secret,
)
if err == nil {
t.Fatalf("Expected the password reset token to be invalidated")
}
user, err = app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
if !user.Verified() {
t.Fatalf("Expected the user to remain verified")
}
},
},
{
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
@@ -1377,114 +1512,227 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
func TestRecordAuthOAuth2Redirect(t *testing.T) {
t.Parallel()
c1 := subscriptions.NewDefaultClient()
clientStubs := make([]map[string]subscriptions.Client, 0, 10)
c2 := subscriptions.NewDefaultClient()
c2.Subscribe("@oauth2")
for i := 0; i < 10; i++ {
c1 := subscriptions.NewDefaultClient()
c3 := subscriptions.NewDefaultClient()
c3.Subscribe("test1", "@oauth2")
c2 := subscriptions.NewDefaultClient()
c2.Subscribe("@oauth2")
c4 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2")
c3 := subscriptions.NewDefaultClient()
c3.Subscribe("test1", "@oauth2")
c5 := subscriptions.NewDefaultClient()
c5.Subscribe("@oauth2")
c5.Discard()
c4 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2")
beforeTestFunc := func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(c1)
app.SubscriptionsBroker().Register(c2)
app.SubscriptionsBroker().Register(c3)
app.SubscriptionsBroker().Register(c4)
app.SubscriptionsBroker().Register(c5)
c5 := subscriptions.NewDefaultClient()
c5.Subscribe("@oauth2")
c5.Discard()
clientStubs = append(clientStubs, map[string]subscriptions.Client{
"c1": c1,
"c2": c2,
"c3": c3,
"c4": c4,
"c5": c5,
})
}
checkFailureRedirect := func(t *testing.T, app *tests.TestApp, res *http.Response) {
loc := res.Header.Get("Location")
if !strings.Contains(loc, "/oauth2-redirect-failure") {
t.Fatalf("Expected failure redirect, got %q", loc)
}
}
checkSuccessRedirect := func(t *testing.T, app *tests.TestApp, res *http.Response) {
loc := res.Header.Get("Location")
if !strings.Contains(loc, "/oauth2-redirect-success") {
t.Fatalf("Expected success redirect, got %q", loc)
}
}
checkClientMessages := func(t *testing.T, 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)
}
if msg.Name != "@oauth2" {
t.Fatalf("Expected @oauth2 msg.Name, got %q", msg.Name)
}
for _, txt := range expectedMessages[clientId] {
if !strings.Contains(string(msg.Data), txt) {
t.Fatalf("Failed to find %q in \n%s", txt, msg.Data)
}
}
}
beforeTestFunc := func(
clients map[string]subscriptions.Client,
expectedMessages map[string][]string,
) func(*testing.T, *tests.TestApp, *echo.Echo) {
return func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
for _, client := range clients {
app.SubscriptionsBroker().Register(client)
}
ctx, cancelFunc := context.WithTimeout(context.Background(), 100*time.Millisecond)
// add to the app store so that it can be cancelled manually after test completion
app.Store().Set("cancelFunc", cancelFunc)
go func() {
defer cancelFunc()
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 <-ctx.Done():
for _, c := range clients {
close(c.Channel())
}
return
}
}
}()
}
}
scenarios := []tests.ApiScenario{
{
Name: "no state query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
Name: "no state query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123",
BeforeTestFunc: beforeTestFunc(clientStubs[0], nil),
ExpectedStatus: http.StatusTemporaryRedirect,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkFailureRedirect(t, app, res)
},
},
{
Name: "no code query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?state=" + c3.Id(),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
Name: "invalid or missing client",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=missing",
BeforeTestFunc: beforeTestFunc(clientStubs[1], nil),
ExpectedStatus: http.StatusTemporaryRedirect,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkFailureRedirect(t, app, res)
},
},
{
Name: "missing client",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=missing",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
Name: "no code query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?state=" + clientStubs[2]["c3"].Id(),
BeforeTestFunc: beforeTestFunc(clientStubs[2], map[string][]string{
"c3": {`"state":"` + clientStubs[2]["c3"].Id(), `"code":""`},
}),
ExpectedStatus: http.StatusTemporaryRedirect,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkFailureRedirect(t, app, res)
if clientStubs[2]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
},
},
{
Name: "discarded client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
Name: "error query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?error=example&code=123&state=" + clientStubs[3]["c3"].Id(),
BeforeTestFunc: beforeTestFunc(clientStubs[3], map[string][]string{
"c3": {`"state":"` + clientStubs[3]["c3"].Id(), `"code":"123"`, `"error":"example"`},
}),
ExpectedStatus: http.StatusTemporaryRedirect,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkFailureRedirect(t, app, res)
if clientStubs[3]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
},
},
{
Name: "client without @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
Name: "discarded client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[4]["c5"].Id(),
BeforeTestFunc: beforeTestFunc(clientStubs[4], nil),
ExpectedStatus: http.StatusTemporaryRedirect,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkFailureRedirect(t, app, res)
},
},
{
Name: "client without @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[4]["c4"].Id(),
BeforeTestFunc: beforeTestFunc(clientStubs[5], nil),
ExpectedStatus: http.StatusTemporaryRedirect,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkFailureRedirect(t, app, res)
},
},
{
Name: "client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
beforeTestFunc(t, app, e)
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
go func() {
defer cancelFunc()
L:
for {
select {
case <-c1.Channel():
t.Error("Unexpected c1 message")
break L
case <-c2.Channel():
t.Error("Unexpected c2 message")
break L
case msg := <-c3.Channel():
if msg.Name != "@oauth2" {
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
}
expectedParams := []string{`"state"`, `"code"`}
for _, p := range expectedParams {
if !strings.Contains(string(msg.Data), p) {
t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
}
}
break L
case <-c4.Channel():
t.Error("Unexpected c4 message")
break L
case <-c5.Channel():
t.Error("Unexpected c5 message")
break L
case <-ctx.Done():
t.Error("Context timeout reached")
break L
}
}
}()
},
Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[6]["c3"].Id(),
BeforeTestFunc: beforeTestFunc(clientStubs[6], map[string][]string{
"c3": {`"state":"` + clientStubs[6]["c3"].Id(), `"code":"123"`},
}),
ExpectedStatus: http.StatusTemporaryRedirect,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkSuccessRedirect(t, app, res)
if clientStubs[6]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
},
},
{
Name: "(POST) client with @oauth2 subscription",
Method: http.MethodPost,
Url: "/api/oauth2-redirect",
Body: strings.NewReader("code=123&state=" + clientStubs[7]["c3"].Id()),
RequestHeaders: map[string]string{
"content-type": "application/x-www-form-urlencoded",
},
BeforeTestFunc: beforeTestFunc(clientStubs[7], map[string][]string{
"c3": {`"state":"` + clientStubs[7]["c3"].Id(), `"code":"123"`},
}),
ExpectedStatus: http.StatusSeeOther,
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkSuccessRedirect(t, app, res)
if clientStubs[7]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
},
},
}
+5
View File
@@ -185,6 +185,11 @@ func (api *recordApi) create(c echo.Context) error {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
// force unset the verified state to prevent ManageRule misuse
if !hasFullManageAccess {
testForm.Verified = false
}
createRuleFunc := func(q *dbx.SelectQuery) error {
if *collection.CreateRule == "" {
return nil // no create rule to resolve
+138
View File
@@ -14,6 +14,8 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRecordCrudList(t *testing.T) {
@@ -1030,6 +1032,20 @@ func TestRecordCrudCreate(t *testing.T) {
t.Fatal(err)
}
formData2, mp2, err2 := tests.MockMultipartData(map[string]string{
rest.MultipartJsonKey: `{"title": "title_test2", "testPayload": 123}`,
}, "files")
if err2 != nil {
t.Fatal(err2)
}
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
rest.MultipartJsonKey: `{"title": "title_test3", "testPayload": 123}`,
}, "files")
if err3 != nil {
t.Fatal(err3)
}
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
@@ -1237,6 +1253,60 @@ func TestRecordCrudCreate(t *testing.T) {
"OnModelAfterCreate": 1,
},
},
{
Name: "submit via multipart form data with @jsonPayload key and unsatisfied @request.data rule",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: formData2,
RequestHeaders: map[string]string{
"Content-Type": mp2.FormDataContentType(),
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatalf("failed to find demo3 collection: %v", err)
}
collection.CreateRule = types.Pointer("@request.data.testPayload != 123")
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
t.Fatalf("failed to update demo3 collection create rule: %v", err)
}
core.ReloadCachedCollections(app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.data rule",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: formData3,
RequestHeaders: map[string]string{
"Content-Type": mp3.FormDataContentType(),
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatalf("failed to find demo3 collection: %v", err)
}
collection.CreateRule = types.Pointer("@request.data.testPayload = 123")
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
t.Fatalf("failed to update demo3 collection create rule: %v", err)
}
core.ReloadCachedCollections(app)
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"`,
`"title":"title_test3"`,
`"files":["`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
{
Name: "unique field error check",
Method: http.MethodPost,
@@ -1608,6 +1678,20 @@ func TestRecordCrudUpdate(t *testing.T) {
t.Fatal(err)
}
formData2, mp2, err2 := tests.MockMultipartData(map[string]string{
rest.MultipartJsonKey: `{"title": "title_test2", "testPayload": 123}`,
}, "files")
if err2 != nil {
t.Fatal(err2)
}
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
rest.MultipartJsonKey: `{"title": "title_test3", "testPayload": 123}`,
}, "files")
if err3 != nil {
t.Fatal(err3)
}
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
@@ -1830,6 +1914,60 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnModelAfterUpdate": 1,
},
},
{
Name: "submit via multipart form data with @jsonPayload key and unsatisfied @request.data rule",
Method: http.MethodPatch,
Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
Body: formData2,
RequestHeaders: map[string]string{
"Content-Type": mp2.FormDataContentType(),
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatalf("failed to find demo3 collection: %v", err)
}
collection.UpdateRule = types.Pointer("@request.data.testPayload != 123")
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
t.Fatalf("failed to update demo3 collection update rule: %v", err)
}
core.ReloadCachedCollections(app)
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.data rule",
Method: http.MethodPatch,
Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
Body: formData3,
RequestHeaders: map[string]string{
"Content-Type": mp3.FormDataContentType(),
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatalf("failed to find demo3 collection: %v", err)
}
collection.UpdateRule = types.Pointer("@request.data.testPayload = 123")
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
t.Fatalf("failed to update demo3 collection update rule: %v", err)
}
core.ReloadCachedCollections(app)
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"mk5fmymtx4wsprk"`,
`"title":"title_test3"`,
`"files":["`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "OnRecordAfterUpdateRequest error response",
Method: http.MethodPatch,
+7 -6
View File
@@ -44,6 +44,7 @@ func RequestInfo(c echo.Context) *models.RequestInfo {
}
result := &models.RequestInfo{
Context: models.RequestInfoContextDefault,
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
@@ -78,7 +79,7 @@ func RecordAuthResponse(
finalizers ...func(token string) error,
) error {
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified {
return NewForbiddenError("Please verify your email first.", nil)
return NewForbiddenError("Please verify your account first.", nil)
}
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
@@ -140,7 +141,7 @@ func RecordAuthResponse(
// EnrichRecord parses the request context and enrich the provided record:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth record and its expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
// are visible only for the current logged admin, record owner or record with manage access
func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
}
@@ -148,12 +149,12 @@ func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultE
// EnrichRecords parses the request context and enriches the provided records:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth records and their expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
// are visible only for the current logged admin, record owner or record with manage access
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
requestInfo := RequestInfo(c)
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
return fmt.Errorf("Failed to resolve email visibility: %w", err)
return fmt.Errorf("failed to resolve email visibility: %w", err)
}
expands := defaultExpands
@@ -166,7 +167,7 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
if len(errs) > 0 {
return fmt.Errorf("Failed to expand: %v", errs)
return fmt.Errorf("failed to expand: %v", errs)
}
return nil
@@ -184,7 +185,7 @@ func expandFetch(
}
if relCollection.ViewRule == nil {
return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
return fmt.Errorf("only admins can view collection %q records", relCollection.Name)
}
if *relCollection.ViewRule != "" {
+2 -2
View File
@@ -87,7 +87,7 @@ func TestRecordAuthResponse(t *testing.T) {
t.Fatal(err)
}
unverfiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
unverifiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
if err != nil {
t.Fatal(err)
}
@@ -108,7 +108,7 @@ func TestRecordAuthResponse(t *testing.T) {
},
{
name: "valid auth record but with unverified email in onlyVerified collection",
record: unverfiedAuthRecord,
record: unverifiedAuthRecord,
expectError: true,
},
{
+6
View File
@@ -84,6 +84,8 @@ func TestSettingsList(t *testing.T) {
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -169,6 +171,8 @@ func TestSettingsSet(t *testing.T) {
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -241,6 +245,8 @@ func TestSettingsSet(t *testing.T) {
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
+24 -18
View File
@@ -28,12 +28,10 @@ func NewAdminCommand(app core.App) *cobra.Command {
func adminCreateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "create",
Example: "admin create test@example.com 1234567890",
Short: "Creates a new admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
Use: "create",
Example: "admin create test@example.com 1234567890",
Short: "Creates a new admin account",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
@@ -51,6 +49,10 @@ func adminCreateCommand(app core.App) *cobra.Command {
admin.Email = args[0]
admin.SetPassword(args[1])
if !app.Dao().HasTable(admin.TableName()) {
return errors.New("Migration are not initialized yet. Please run 'migrate up' and try again.")
}
if err := app.Dao().SaveAdmin(admin); err != nil {
return fmt.Errorf("Failed to create new admin account: %v", err)
}
@@ -65,12 +67,10 @@ func adminCreateCommand(app core.App) *cobra.Command {
func adminUpdateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "update",
Example: "admin update test@example.com 1234567890",
Short: "Changes the password of a single admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
Use: "update",
Example: "admin update test@example.com 1234567890",
Short: "Changes the password of a single admin account",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
@@ -84,6 +84,10 @@ func adminUpdateCommand(app core.App) *cobra.Command {
return errors.New("The new password must be at least 8 chars long.")
}
if !app.Dao().HasTable((&models.Admin{}).TableName()) {
return errors.New("Migration are not initialized yet. Please run 'migrate up' and try again.")
}
admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil {
return fmt.Errorf("Admin with email %s doesn't exist.", args[0])
@@ -105,17 +109,19 @@ func adminUpdateCommand(app core.App) *cobra.Command {
func adminDeleteCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "delete",
Example: "admin delete test@example.com",
Short: "Deletes an existing admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
Use: "delete",
Example: "admin delete test@example.com",
Short: "Deletes an existing admin account",
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.")
}
if !app.Dao().HasTable((&models.Admin{}).TableName()) {
return errors.New("Migration are not initialized yet. Please run 'migrate up' and try again.")
}
admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil {
color.Yellow("Admin %s is already deleted.", args[0])
+10 -7
View File
@@ -1,7 +1,7 @@
package cmd
import (
"log"
"errors"
"net/http"
"github.com/pocketbase/pocketbase/apis"
@@ -17,10 +17,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
var httpsAddr string
command := &cobra.Command{
Use: "serve [domain(s)]",
Args: cobra.ArbitraryArgs,
Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
Run: func(command *cobra.Command, args []string) {
Use: "serve [domain(s)]",
Args: cobra.ArbitraryArgs,
Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
// set default listener addresses if at least one domain is specified
if len(args) > 0 {
if httpAddr == "" {
@@ -43,9 +44,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
CertificateDomains: args,
})
if err != http.ErrServerClosed {
log.Fatalln(err)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
},
}
+41 -22
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@@ -599,10 +600,10 @@ func (app *BaseApp) RefreshSettings() error {
return err
}
// reload handler level (if initialized and not in dev mode)
if !app.IsDev() && app.Logger() != nil {
// reload handler level (if initialized)
if app.Logger() != nil {
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
h.SetLevel(slog.Level(app.settings.Logs.MinLevel))
h.SetLevel(app.getLoggerMinLevel())
}
}
@@ -1161,7 +1162,9 @@ func (app *BaseApp) registerDefaultHooks() {
// try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
prefix := m.BaseFilesPath()
// ensure that there is a trailing slash so that the list iterator could start walking from the prefix
// (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
@@ -1182,6 +1185,29 @@ func (app *BaseApp) registerDefaultHooks() {
if err := app.initAutobackupHooks(); err != nil {
app.Logger().Error("Failed to init auto backup hooks", slog.String("error", err.Error()))
}
registerCachedCollectionsAppHooks(app)
}
// getLoggerMinLevel returns the logger min level based on the
// app configurations (dev mode, settings, etc.).
//
// If not in dev mode - returns the level from the app settings.
//
// If the app is in dev mode it returns -9999 level allowing to print
// practically all logs to the terminal.
// In this case DB logs are still filtered but the checks for the min level are done
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
func (app *BaseApp) getLoggerMinLevel() slog.Level {
var minLevel slog.Level
if app.IsDev() {
minLevel = -9999
} else if app.Settings() != nil {
minLevel = slog.Level(app.Settings().Logs.MinLevel)
}
return minLevel
}
func (app *BaseApp) initLogger() error {
@@ -1189,20 +1215,8 @@ func (app *BaseApp) initLogger() error {
ticker := time.NewTicker(duration)
done := make(chan bool)
// Apply the min level only if it is not in develop
// to allow printing the logs to the console.
//
// DB logs are still filtered but the checks for the min level are done
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
var minLevel slog.Level
if app.IsDev() {
minLevel = -9999
} else if app.Settings() != nil {
minLevel = slog.Level(app.Settings().Logs.MinLevel)
}
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: minLevel,
Level: app.getLoggerMinLevel(),
BatchSize: 200,
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
if app.IsDev() {
@@ -1245,14 +1259,14 @@ func (app *BaseApp) initLogger() error {
return nil
})
// @todo replace with cron so that it doesn't rely on the logs write
//
// delete old logs
// ---
logsMaxDays := app.Settings().Logs.MaxDays
now := time.Now()
lastLogsDeletedAt := cast.ToTime(app.Store().Get("lastLogsDeletedAt"))
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
if daysDiff > float64(logsMaxDays) {
deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*logsMaxDays))
if now.Sub(lastLogsDeletedAt).Hours() >= 6 {
deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
if deleteErr == nil {
app.Store().Set("lastLogsDeletedAt", now)
} else {
@@ -1270,7 +1284,7 @@ func (app *BaseApp) initLogger() error {
for {
select {
case <-done:
handler.WriteAll(ctx)
return
case <-ticker.C:
handler.WriteAll(ctx)
}
@@ -1280,8 +1294,13 @@ func (app *BaseApp) initLogger() error {
app.logger = slog.New(handler)
app.OnTerminate().PreAdd(func(e *TerminateEvent) error {
// write all remaining logs before ticker.Stop to avoid races with ResetBootstrap user calls
handler.WriteAll(context.Background())
ticker.Stop()
done <- true
return nil
})
+10
View File
@@ -248,6 +248,16 @@ func (app *BaseApp) initAutobackupHooks() error {
loadJob := func() {
c.Stop()
// make sure that app.Settings() is always up to date
//
// @todo remove with the refactoring as core.App and daos.Dao will be one.
if err := app.RefreshSettings(); err != nil {
app.Logger().Debug(
"[Backup cron] Failed to get the latest app settings",
slog.String("error", err.Error()),
)
}
rawSchedule := app.Settings().Backups.Cron
if rawSchedule == "" || !isServe || !app.IsBootstrapped() {
return
+102 -13
View File
@@ -1,12 +1,16 @@
package core
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
@@ -15,6 +19,7 @@ import (
"github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestNewBaseApp(t *testing.T) {
@@ -281,7 +286,7 @@ func TestBaseAppLoggerWrites(t *testing.T) {
}
defer cleanup()
threshold := 200
const logsThreshold = 200
totalLogs := func(app App, t *testing.T) int {
var total int
@@ -294,24 +299,22 @@ func TestBaseAppLoggerWrites(t *testing.T) {
return total
}
// disabled logs retention
{
t.Run("disabled logs retention", func(t *testing.T) {
app.Settings().Logs.MaxDays = 0
for i := 0; i < threshold+1; i++ {
for i := 0; i < logsThreshold+1; i++ {
app.Logger().Error("test")
}
if total := totalLogs(app, t); total != 0 {
t.Fatalf("Expected no logs, got %d", total)
}
}
})
// test batch logs writes
{
t.Run("test batch logs writes", func(t *testing.T) {
app.Settings().Logs.MaxDays = 1
for i := 0; i < threshold-1; i++ {
for i := 0; i < logsThreshold-1; i++ {
app.Logger().Error("test")
}
@@ -325,16 +328,102 @@ func TestBaseAppLoggerWrites(t *testing.T) {
// should be added for the next batch write
app.Logger().Error("test")
if total := totalLogs(app, t); total != threshold {
t.Fatalf("Expected %d logs, got %d", threshold, total)
if total := totalLogs(app, t); total != logsThreshold {
t.Fatalf("Expected %d logs, got %d", logsThreshold, total)
}
// wait for ~3 secs to check the timer trigger
time.Sleep(3200 * time.Millisecond)
if total := totalLogs(app, t); total != threshold+1 {
t.Fatalf("Expected %d logs, got %d", threshold+1, total)
if total := totalLogs(app, t); total != logsThreshold+1 {
t.Fatalf("Expected %d logs, got %d", logsThreshold+1, total)
}
}
})
t.Run("test batch logs delete", func(t *testing.T) {
app.Settings().Logs.MaxDays = 2
deleteQueries := 0
// reset
app.Store().Set("lastLogsDeletedAt", time.Now())
if err := app.LogsDao().DeleteOldLogs(time.Now()); err != nil {
t.Fatal(err)
}
db := app.LogsDao().NonconcurrentDB().(*dbx.DB)
db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
if strings.Contains(sql, "DELETE") {
deleteQueries++
}
}
// trigger batch write (A)
expectedLogs := logsThreshold
for i := 0; i < expectedLogs; i++ {
app.Logger().Error("testA")
}
if total := totalLogs(app, t); total != expectedLogs {
t.Fatalf("[batch write A] Expected %d logs, got %d", expectedLogs, total)
}
// mark the A inserted logs as 2-day expired
aExpiredDate, err := types.ParseDateTime(time.Now().AddDate(0, 0, -2))
if err != nil {
t.Fatal(err)
}
_, err = app.LogsDao().NonconcurrentDB().NewQuery("UPDATE _logs SET created={:date}, updated={:date}").Bind(dbx.Params{
"date": aExpiredDate.String(),
}).Execute()
if err != nil {
t.Fatalf("Failed to mock logs timestamp fields: %v", err)
}
// simulate recently deleted logs
app.Store().Set("lastLogsDeletedAt", time.Now().Add(-5*time.Hour))
// trigger batch write (B)
for i := 0; i < logsThreshold; i++ {
app.Logger().Error("testB")
}
expectedLogs = 2 * logsThreshold
// note: even though there are expired logs it shouldn't perform the delete operation because of the lastLogsDeledAt time
if total := totalLogs(app, t); total != expectedLogs {
t.Fatalf("[batch write B] Expected %d logs, got %d", expectedLogs, total)
}
// mark the B inserted logs as 1-day expired to ensure that they will not be deleted
bExpiredDate, err := types.ParseDateTime(time.Now().AddDate(0, 0, -1))
if err != nil {
t.Fatal(err)
}
_, err = app.LogsDao().NonconcurrentDB().NewQuery("UPDATE _logs SET created={:date}, updated={:date} where message='testB'").Bind(dbx.Params{
"date": bExpiredDate.String(),
}).Execute()
if err != nil {
t.Fatalf("Failed to mock logs timestamp fields: %v", err)
}
// should trigger delete on the next batch write
app.Store().Set("lastLogsDeletedAt", time.Now().Add(-6*time.Hour))
// trigger batch write (C)
for i := 0; i < logsThreshold; i++ {
app.Logger().Error("testC")
}
expectedLogs = 2 * logsThreshold // only B and C logs should remain
if total := totalLogs(app, t); total != expectedLogs {
t.Fatalf("[batch write C] Expected %d logs, got %d", expectedLogs, total)
}
if deleteQueries != 1 {
t.Fatalf("Expected DeleteOldLogs to be called %d, got %d", 1, deleteQueries)
}
})
}
func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
+72
View File
@@ -0,0 +1,72 @@
package core
// -------------------------------------------------------------------
// This is a small optimization ported from the [ongoing refactoring branch](https://github.com/pocketbase/pocketbase/discussions/4355).
//
// @todo remove after the refactoring is finalized.
// -------------------------------------------------------------------
import (
"strings"
"github.com/pocketbase/pocketbase/models"
)
const storeCachedCollectionsKey = "@cachedCollectionsContext"
func registerCachedCollectionsAppHooks(app App) {
collectionsChangeFunc := func(e *ModelEvent) error {
if _, ok := e.Model.(*models.Collection); !ok {
return nil
}
_ = ReloadCachedCollections(app)
return nil
}
app.OnModelAfterCreate().Add(collectionsChangeFunc)
app.OnModelAfterUpdate().Add(collectionsChangeFunc)
app.OnModelAfterDelete().Add(collectionsChangeFunc)
app.OnBeforeServe().Add(func(e *ServeEvent) error {
_ = ReloadCachedCollections(e.App)
return nil
})
}
func ReloadCachedCollections(app App) error {
collections := []*models.Collection{}
err := app.Dao().CollectionQuery().All(&collections)
if err != nil {
return err
}
app.Store().Set(storeCachedCollectionsKey, collections)
return nil
}
func FindCachedCollectionByNameOrId(app App, nameOrId string) (*models.Collection, error) {
// retrieve from the app cache
// ---
collections, _ := app.Store().Get(storeCachedCollectionsKey).([]*models.Collection)
for _, c := range collections {
if strings.EqualFold(c.Name, nameOrId) || c.Id == nameOrId {
return c, nil
}
}
// retrieve from the database
// ---
found, err := app.Dao().FindCollectionByNameOrId(nameOrId)
if err != nil {
return nil, err
}
err = ReloadCachedCollections(app)
if err != nil {
app.Logger().Warn("Failed to reload collections cache", "error", err)
}
return found, nil
}
+2 -2
View File
@@ -59,7 +59,7 @@ func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Adm
// check required claims
id, _ := unverifiedClaims["id"].(string)
if id == "" {
return nil, errors.New("Missing or invalid token claims.")
return nil, errors.New("missing or invalid token claims")
}
admin, err := dao.FindAdminById(id)
@@ -116,7 +116,7 @@ func (dao *Dao) DeleteAdmin(admin *models.Admin) error {
}
if total == 1 {
return errors.New("You cannot delete the only existing admin.")
return errors.New("you cannot delete the only existing admin")
}
return dao.Delete(admin)
+14 -14
View File
@@ -18,7 +18,7 @@ func New(db dbx.Builder) *Dao {
return NewMultiDB(db, db)
}
// New creates a new Dao instance with the provided dedicated
// NewMultiDB creates a new Dao instance with the provided dedicated
// async and sync db builders.
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
return &Dao{
@@ -87,16 +87,16 @@ func (dao *Dao) Clone() *Dao {
// WithoutHooks returns a new Dao with the same configuration options
// as the current one, but without create/update/delete hooks.
func (dao *Dao) WithoutHooks() *Dao {
new := dao.Clone()
clone := dao.Clone()
new.BeforeCreateFunc = nil
new.AfterCreateFunc = nil
new.BeforeUpdateFunc = nil
new.AfterUpdateFunc = nil
new.BeforeDeleteFunc = nil
new.AfterDeleteFunc = nil
clone.BeforeCreateFunc = nil
clone.AfterCreateFunc = nil
clone.BeforeUpdateFunc = nil
clone.AfterUpdateFunc = nil
clone.BeforeDeleteFunc = nil
clone.AfterDeleteFunc = nil
return new
return clone
}
// ModelQuery creates a new preconfigured select query with preset
@@ -119,9 +119,9 @@ func (dao *Dao) FindById(m models.Model, id string) error {
}
type afterCallGroup struct {
Action string
EventDao *Dao
Model models.Model
EventDao *Dao
Action string
}
// RunInTransaction wraps fn into a transaction.
@@ -169,19 +169,19 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
if dao.AfterCreateFunc != nil {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
afterCalls = append(afterCalls, afterCallGroup{m, eventDao, "create"})
return nil
}
}
if dao.AfterUpdateFunc != nil {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
afterCalls = append(afterCalls, afterCallGroup{m, eventDao, "update"})
return nil
}
}
if dao.AfterDeleteFunc != nil {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
afterCalls = append(afterCalls, afterCallGroup{m, eventDao, "delete"})
return nil
}
}
+10 -10
View File
@@ -121,7 +121,7 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
// - is referenced as part of a relation field in another collection
func (dao *Dao) DeleteCollection(collection *models.Collection) error {
if collection.System {
return fmt.Errorf("System collection %q cannot be deleted.", collection.Name)
return fmt.Errorf("system collection %q cannot be deleted", collection.Name)
}
// ensure that there aren't any existing references.
@@ -135,7 +135,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
for ref := range result {
names = append(names, ref.Name)
}
return fmt.Errorf("The collection %q has external relation field references (%s).", collection.Name, strings.Join(names, ", "))
return fmt.Errorf("the collection %q has external relation field references (%s)", collection.Name, strings.Join(names, ", "))
}
return dao.RunInTransaction(func(txDao *Dao) error {
@@ -152,7 +152,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
// trigger views resave to check for dependencies
if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil {
return fmt.Errorf("The collection has a view dependency - %w", err)
return fmt.Errorf("the collection has a view dependency - %w", err)
}
return txDao.Delete(collection)
@@ -162,8 +162,8 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
// SaveCollection persists the provided Collection model and updates
// its related records table schema.
//
// If collecction.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a collection for update you can use collecction.MarkAsNotNew().
// If collection.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a collection for update you can use collection.MarkAsNotNew().
func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection
@@ -227,7 +227,7 @@ func (dao *Dao) ImportCollections(
afterSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
) error {
if len(importedCollections) == 0 {
return errors.New("No collections to import")
return errors.New("no collections to import")
}
return dao.RunInTransaction(func(txDao *Dao) error {
@@ -263,11 +263,11 @@ func (dao *Dao) ImportCollections(
// extend existing schema
if !deleteMissing {
schema, _ := existing.Schema.Clone()
schemaClone, _ := existing.Schema.Clone()
for _, f := range imported.Schema.Fields() {
schema.AddField(f) // add or replace
schemaClone.AddField(f) // add or replace
}
imported.Schema = *schema
imported.Schema = *schemaClone
}
} else {
imported.MarkAsNew()
@@ -285,7 +285,7 @@ func (dao *Dao) ImportCollections(
}
if existing.System {
return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
return fmt.Errorf("system collection %q cannot be deleted", existing.Name)
}
// delete the related records table or view
+30 -27
View File
@@ -153,9 +153,11 @@ func TestFindCollectionReferences(t *testing.T) {
"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 {
@@ -756,7 +758,7 @@ func TestImportCollections(t *testing.T) {
"demo1": 15,
"demo2": 2,
"demo3": 2,
"demo4": 11,
"demo4": 13,
"demo5": 6,
"new_import": 1,
}
@@ -774,37 +776,38 @@ func TestImportCollections(t *testing.T) {
},
}
for _, scenario := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
importedCollections := []*models.Collection{}
importedCollections := []*models.Collection{}
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections)
if loadErr != nil {
t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr)
continue
}
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), &importedCollections)
if loadErr != nil {
t.Fatalf("Failed to load data: %v", loadErr)
}
err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync)
err := testApp.Dao().ImportCollections(importedCollections, s.deleteMissing, s.beforeRecordsSync)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err)
}
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != scenario.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections))
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != s.expectCollectionsCount {
t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
}
if scenario.afterTestFunc != nil {
scenario.afterTestFunc(testApp, collections)
}
if s.afterTestFunc != nil {
s.afterTestFunc(testApp, collections)
}
})
}
}
+13 -15
View File
@@ -40,16 +40,16 @@ func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
collection, collectionErr = dao.FindCollectionByNameOrId(c)
if collection != nil {
tableName = collection.Name
} else {
// update with some fake table name for easier debugging
tableName = "@@__missing_" + c
}
default:
// update with some fake table name for easier debugging
tableName = "@@__invalidCollectionModelOrIdentifier"
collectionErr = errors.New("unsupported collection identifier, must be collection model, id or name")
}
// update with some fake table name for easier debugging
if tableName == "" {
tableName = "@@__invalidCollectionModelOrIdentifier"
}
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
query := dao.DB().Select(selectCols).From(tableName)
@@ -198,8 +198,6 @@ func (dao *Dao) FindRecordsByIds(
return records, nil
}
// @todo consider to depricate as it may be easier to just use dao.RecordQuery()
//
// FindRecordsByExpr finds all records by the specified db expression.
//
// Returns all collection records if no expressions are provided.
@@ -432,7 +430,7 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
}
if !record.Collection().IsAuth() {
return nil, errors.New("The token is not associated to an auth collection record.")
return nil, errors.New("the token is not associated to an auth collection record")
}
verificationKey := record.TokenKey() + baseTokenKey
@@ -659,8 +657,6 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
//
// NB! This method is expected to be called inside a transaction.
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
// @todo consider changing refs to a slice
//
// Sort the refs keys to ensure that the cascade events firing order is always the same.
@@ -684,15 +680,17 @@ func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
query := dao.RecordQuery(refCollection).Distinct(true)
query := dao.RecordQuery(refCollection)
if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
} else {
query.InnerJoin(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
`SELECT 1 FROM json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName,
), dbx.Params{
"jevalue": mainRecord.Id,
})))
}
if refCollection.Id == mainRecord.Collection().Id {
+61 -39
View File
@@ -1,7 +1,9 @@
package daos
import (
"errors"
"fmt"
"log"
"regexp"
"strings"
@@ -9,13 +11,14 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
)
// MaxExpandDepth specifies the max allowed nested expand depth path.
//
// @todo Consider eventually reusing resolvers.maxNestedRels
const MaxExpandDepth = 6
// ExpandFetchFunc defines the function that is used to fetch the expanded relation records.
@@ -51,13 +54,15 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, optFet
return failed
}
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
// Deprecated
var indirectExpandRegexOld = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
var indirectExpandRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
// notes:
// - if fetchFunc is nil, dao.FindRecordsByIds will be used
// - all records are expected to be from the same collection
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
// - indirect expands are supported only with single relation fields
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
if fetchFunc == nil {
// load a default fetchFunc
@@ -77,70 +82,87 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
var relCollection *models.Collection
parts := strings.SplitN(expandPath, ".", 2)
matches := indirectExpandRegex.FindStringSubmatch(parts[0])
var matches []string
// @todo remove the old syntax support
if strings.Contains(parts[0], "(") {
matches = indirectExpandRegexOld.FindStringSubmatch(parts[0])
if len(matches) == 3 {
log.Printf(
"%s expand format is deprecated and will be removed in the future. Consider replacing it with %s_via_%s.\n",
matches[0],
matches[1],
matches[2],
)
}
} else {
matches = indirectExpandRegex.FindStringSubmatch(parts[0])
}
if len(matches) == 3 {
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
if indirectRel == nil {
return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
return fmt.Errorf("couldn't find back-related collection %q", matches[1])
}
indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
return fmt.Errorf("couldn't find back-relation field %q in collection %q", matches[2], indirectRel.Name)
}
indirectRelField.InitOptions()
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
}
if indirectRelFieldOptions.IsMultiple() {
// for now don't allow multi-relation indirect fields expand
// due to eventual poor query performance with large data sets.
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
return fmt.Errorf("invalid back-relation field path %q", parts[0])
}
recordIds := make([]any, len(records))
for i, record := range records {
recordIds[i] = record.Id
}
// add the related id(s) as a dynamic relation field value to
// allow further expand checks at later stage in a more unified manner
prepErr := func() error {
q := dao.DB().Select("id").
From(indirectRel.Name).
Limit(1000) // the limit is arbitrary chosen and may change in the future
// @todo after the index optimizations consider allowing
// indirect expand for multi-relation fields
indirectRecords, err := dao.FindRecordsByExpr(
indirectRel.Id,
dbx.In(inflector.Columnify(matches[2]), recordIds...),
)
if err != nil {
return err
}
mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
for _, indirectRecord := range indirectRecords {
recId := indirectRecord.GetString(matches[2])
if recId != "" {
mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
if indirectRelFieldOptions.IsMultiple() {
q.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
"SELECT 1 FROM %s je WHERE je.value = {:id}",
dbutils.JsonEach(indirectRelField.Name),
))))
} else {
q.AndWhere(dbx.NewExp("[[" + indirectRelField.Name + "]] = {:id}"))
}
}
// add the indirect relation ids as a new relation field value
for _, record := range records {
relIds, ok := mappedIndirectRecordIds[record.Id]
if ok && len(relIds) > 0 {
record.Set(parts[0], relIds)
pq := q.Build().Prepare()
for _, record := range records {
var relIds []string
err := pq.Bind(dbx.Params{"id": record.Id}).Column(&relIds)
if err != nil {
return errors.Join(err, pq.Close())
}
if len(relIds) > 0 {
record.Set(parts[0], relIds)
}
}
return pq.Close()
}()
if prepErr != nil {
return prepErr
}
relFieldOptions = &schema.RelationOptions{
MaxSelect: nil,
CollectionId: indirectRel.Id,
}
if isRelFieldUnique(indirectRel, indirectRelField.Name) {
if dbutils.HasSingleColumnUniqueIndex(indirectRelField.Name, indirectRel.Indexes) {
relFieldOptions.MaxSelect = types.Pointer(1)
}
// indirect relation
// indirect/back relation
relField = &schema.SchemaField{
Id: "indirect_" + security.PseudorandomString(5),
Id: "_" + parts[0] + security.PseudorandomString(3),
Type: schema.FieldTypeRelation,
Name: parts[0],
Options: relFieldOptions,
+61 -13
View File
@@ -163,7 +163,7 @@ func TestExpandRecords(t *testing.T) {
0,
},
{
"simple indirect expand",
"simple back single relation field expand (deprecated syntax)",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{"demo4(rel_one_no_cascade_required)"},
@@ -174,11 +174,22 @@ func TestExpandRecords(t *testing.T) {
0,
},
{
"nested indirect expand",
"simple back expand via single relation field",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{"demo4_via_rel_one_no_cascade_required"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
1,
0,
},
{
"nested back expand via single relation field",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
"demo4_via_rel_one_no_cascade_required.self_rel_many.self_rel_many.self_rel_one",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
@@ -186,6 +197,19 @@ func TestExpandRecords(t *testing.T) {
5,
0,
},
{
"nested back expand via multiple relation field",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{
"demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
7,
0,
},
{
"expand multiple relations sharing a common path",
"demo4",
@@ -332,7 +356,7 @@ func TestExpandRecord(t *testing.T) {
0,
},
{
"simple indirect expand",
"simple indirect expand via single relation field (deprecated syntax)",
"demo3",
"lcl9d87w22ml6jy",
[]string{"demo4(rel_one_no_cascade_required)"},
@@ -343,7 +367,18 @@ func TestExpandRecord(t *testing.T) {
0,
},
{
"nested indirect expand",
"simple indirect expand via single relation field",
"demo3",
"lcl9d87w22ml6jy",
[]string{"demo4_via_rel_one_no_cascade_required"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
1,
0,
},
{
"nested indirect expand via single relation field",
"demo3",
"lcl9d87w22ml6jy",
[]string{
@@ -355,6 +390,19 @@ func TestExpandRecord(t *testing.T) {
5,
0,
},
{
"nested indirect expand via single relation field",
"demo3",
"lcl9d87w22ml6jy",
[]string{
"demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
7,
0,
},
}
for _, s := range scenarios {
@@ -388,21 +436,23 @@ func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
errs := app.Dao().ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].([]*models.Record)
result, ok := record.Expand()["demo4_via_rel_one_cascade"].([]*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a slice, got %v", result)
}
}
// mock a unique constraint for the rel_one_cascade field
// unique indirect expand
{
// mock a unique constraint for the rel_one_cascade field
// ---
demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
@@ -413,18 +463,16 @@ func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
if err := app.Dao().SaveCollection(demo4); err != nil {
t.Fatalf("Failed to mock unique constraint: %v", err)
}
}
// ---
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
errs := app.Dao().ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].(*models.Record)
result, ok := record.Expand()["demo4_via_rel_one_cascade"].(*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a single model, got %v", result)
}
+1
View File
@@ -37,6 +37,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastLoginAlertSentAt] = "TEXT DEFAULT '' NOT NULL"
}
// ensure that the new collection has an id
+1 -1
View File
@@ -83,7 +83,7 @@ func TestSyncRecordTableSchema(t *testing.T) {
[]string{
"id", "created", "updated", "test",
"username", "email", "verified", "emailVisibility",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt", "lastLoginAlertSentAt",
},
4,
},
+2 -2
View File
@@ -1205,11 +1205,11 @@ func TestDeleteRecord(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyPart := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
}
expectedRelOnePart := "SELECT DISTINCT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`="
expectedRelOnePart := "SELECT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`='"
if !strings.Contains(joinedQueries, expectedRelOnePart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
}
+15
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"regexp"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
@@ -131,6 +132,7 @@ func (form *CollectionUpsert) Validate() error {
validation.Match(collectionNameRegex),
validation.By(form.ensureNoSystemNameChange),
validation.By(form.checkUniqueName),
validation.By(form.checkForVia),
),
// validates using the type's own validation rules + some collection's specifics
validation.Field(
@@ -163,6 +165,19 @@ func (form *CollectionUpsert) Validate() error {
)
}
func (form *CollectionUpsert) checkForVia(value any) error {
v, _ := value.(string)
if v == "" {
return nil
}
if strings.Contains(strings.ToLower(v), "_via_") {
return validation.NewError("validation_invalid_name", "The name of the collection cannot contain '_via_'.")
}
return nil
}
func (form *CollectionUpsert) checkUniqueName(value any) error {
v, _ := value.(string)
+11
View File
@@ -105,6 +105,17 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
{"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
{"empty update", "demo2", "{}", []string{}},
{
"collection and field with _via_ names",
"",
`{
"name": "a_via_b",
"schema": [
{"name":"c_via_d","type":"text"}
]
}`,
[]string{"name", "schema"},
},
{
"create failure",
"",
+18 -9
View File
@@ -222,15 +222,11 @@ func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
// load custom data
createForm.LoadData(form.CreateData)
// load the OAuth2 profile data as fallback
if createForm.Email == "" {
createForm.Email = data.OAuth2User.Email
}
createForm.Verified = false
if createForm.Email == data.OAuth2User.Email {
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
createForm.Verified = true
}
// load the OAuth2 user data
createForm.Email = data.OAuth2User.Email
createForm.Verified = true // mark as verified as long as it matches the OAuth2 data (even if the email is empty)
// generate a random password if not explicitly set
if createForm.Password == "" {
createForm.Password = security.RandomString(30)
createForm.PasswordConfirm = createForm.Password
@@ -247,6 +243,19 @@ func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
return err
}
} else {
isLoggedAuthRecord := form.loggedAuthRecord != nil &&
form.loggedAuthRecord.Id == data.Record.Id &&
form.loggedAuthRecord.Collection().Id == data.Record.Collection().Id
// set random password for users with unverified email
// (this is in case a malicious actor has registered via password using the user email)
if !isLoggedAuthRecord && data.Record.Email() != "" && !data.Record.Verified() {
data.Record.SetPassword(security.RandomString(30))
if err := txDao.SaveRecord(data.Record); err != nil {
return err
}
}
// update the existing auth record empty email if the data.OAuth2User has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
+15 -1
View File
@@ -6,6 +6,8 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
// RecordPasswordResetConfirm is an auth record password reset confirmation form.
@@ -91,9 +93,21 @@ func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*
return nil, err
}
if !authRecord.Verified() {
payload, err := security.ParseUnverifiedJWT(form.Token)
if err != nil {
return nil, err
}
// mark as verified if the email hasn't changed
if authRecord.Email() == cast.ToString(payload["email"]) {
authRecord.SetVerified(true)
}
}
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
return form.dao.SaveRecord(m)
return form.dao.SaveRecord(authRecord)
}, interceptors...)
if interceptorsErr != nil {
+1 -1
View File
@@ -46,7 +46,7 @@ func (form *RecordPasswordResetRequest) SetDao(dao *daos.Dao) {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
//
// This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit).
// This method doesn't check whether auth record with `form.Email` exists (this is done on Submit).
func (form *RecordPasswordResetRequest) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
+14 -4
View File
@@ -165,7 +165,7 @@ func (form *RecordUpsert) extractMultipartFormData(
data := map[string]any{}
filesToUpload := map[string][]*filesystem.File{}
arrayValueSupportTypes := schema.ArraybleFieldTypes()
arraybleFieldTypes := schema.ArraybleFieldTypes()
for fullKey, values := range r.PostForm {
key := fullKey
@@ -178,8 +178,18 @@ func (form *RecordUpsert) extractMultipartFormData(
continue
}
// special case for multipart json encoded fields
if key == rest.MultipartJsonKey {
for _, v := range values {
if err := json.Unmarshal([]byte(v), &data); err != nil {
form.app.Logger().Debug("Failed to decode @json value into the data map", "error", err, "value", v)
}
}
continue
}
field := form.record.Collection().Schema.GetFieldByName(key)
if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) {
if field != nil && list.ExistInSlice(field.Type, arraybleFieldTypes) {
data[key] = values
} else {
data[key] = values[0]
@@ -313,10 +323,10 @@ func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error
// Example
//
// // mark only only 2 files for removal
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
// form.RemoveFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
//
// // mark all "documents" files for removal
// form.AddFiles("documents")
// form.RemoveFiles("documents")
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile {
+18 -3
View File
@@ -22,6 +22,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -150,9 +151,10 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
}
formData, mp, err := tests.MockMultipartData(map[string]string{
"a.b.id": "test_id",
"a.b.text": "test123",
"a.b.unknown": "test456",
"a.b.id": "test_id",
"a.b.text": "test123",
"a.b.unknown": "test456",
"a.b." + rest.MultipartJsonKey: `{"json":["a","b"],"email":"test3@example.com"}`,
// file fields unset/delete
"a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier
"a.b.file_many.0": "", // delete by index
@@ -184,6 +186,19 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
}
if v, ok := form.Data()["email"]; !ok || v != "test3@example.com" {
t.Fatalf("Expect email field to be %q, got %q", "test3@example.com", v)
}
rawJsonValue, ok := form.Data()["json"].(types.JsonRaw)
if !ok {
t.Fatal("Expect json field to be set")
}
expectedJsonValue := `["a","b"]`
if rawJsonValue.String() != expectedJsonValue {
t.Fatalf("Expect json field %v, got %v", expectedJsonValue, rawJsonValue)
}
fileOne, ok := form.Data()["file_one"]
if !ok {
t.Fatal("Expect file_one field to be set")
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/pocketbase/pocketbase/daos"
)
// Compare checks whether the provided model id exists.
// UniqueId checks whether the provided model id already exists.
//
// Example:
//
+55 -60
View File
@@ -4,93 +4,88 @@ go 1.21
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go v1.49.20
github.com/aws/aws-sdk-go-v2 v1.30.3
github.com/aws/aws-sdk-go-v2/config v1.27.27
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.8
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
github.com/aws/smithy-go v1.20.3
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c
github.com/fatih/color v1.16.0
github.com/dop251/goja v0.0.0-20240627195025-eb1f15ee67d2
github.com/dop251/goja_nodejs v0.0.0-20240418154818-2aae10d4cbcf
github.com/fatih/color v1.17.0
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/ganigeorgiev/fexpr v0.4.0
github.com/gabriel-vasile/mimetype v1.4.4
github.com/ganigeorgiev/fexpr v0.4.1
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/goccy/go-json v0.10.2
github.com/goccy/go-json v0.10.3
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/mattn/go-sqlite3 v1.14.19
github.com/mattn/go-sqlite3 v1.14.22
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/tygoja v0.0.0-20240113091827-17918475d342
github.com/spf13/cast v1.6.0
github.com/spf13/cobra v1.8.0
gocloud.dev v0.36.0
golang.org/x/crypto v0.18.0
golang.org/x/net v0.20.0
golang.org/x/oauth2 v0.16.0
golang.org/x/sync v0.6.0
modernc.org/sqlite v1.28.0
github.com/spf13/cobra v1.8.1
gocloud.dev v0.37.0
golang.org/x/crypto v0.25.0
golang.org/x/net v0.27.0
golang.org/x/oauth2 v0.21.0
golang.org/x/sync v0.7.0
modernc.org/sqlite v1.31.1
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.26.3 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/aws/aws-sdk-go v1.51.11 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/term v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/term v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.17.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/api v0.156.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1 // indirect
google.golang.org/grpc v1.60.1 // indirect
google.golang.org/protobuf v1.32.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.40.1 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
google.golang.org/api v0.189.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+162 -266
View File
@@ -1,14 +1,19 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w=
cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY=
cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -17,92 +22,50 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.48.16 h1:mcj2/9J/MJ55Dov+ocMevhR8Jv6jW/fAxbrn4a1JFc8=
github.com/aws/aws-sdk-go v1.48.16/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go v1.49.15 h1:aH9bSV4kL4ziH0AMtuYbukGIVebXddXBL0cKZ1zj15k=
github.com/aws/aws-sdk-go v1.49.15/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go v1.49.20 h1:VgEUq2/ZbUkLbqPyDcxrirfXB+PgiZUUF5XbsgWe2S0=
github.com/aws/aws-sdk-go v1.49.20/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk=
github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o=
github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg=
github.com/aws/aws-sdk-go-v2/config v1.26.3 h1:dKuc2jdp10y13dEEvPqWxqLoc0vF3Z9FC45MvuQSxOA=
github.com/aws/aws-sdk-go-v2/config v1.26.3/go.mod h1:Bxgi+DeeswYofcYO0XyGClwlrq3DZEXli0kLf4hkGA0=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ=
github.com/aws/aws-sdk-go-v2/credentials v1.16.14 h1:mMDTwwYO9A0/JbOCOG7EOZHtYM+o7OfGWfu0toa23VE=
github.com/aws/aws-sdk-go-v2/credentials v1.16.14/go.mod h1:cniAUh3ErQPHtCQGPT5ouvSAQ0od8caTO9OOuufZOAE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 h1:FnLf60PtjXp8ZOzQfhJVsqF0OtYKQZWQfqOLshh8YXg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7/go.mod h1:tDVvl8hyU6E9B8TrnNrZQEVkQlB8hjJwcgpPhgtlnNg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.10 h1:SdMso4tShJKrwGmwZPMO6urFilhTYkEZUPsndW0unfM=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.10/go.mod h1:qi+Nerp7JHgl+eyVtiRPA7T4bV5onFRWgpnF2JzPW60=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.11 h1:I6lAa3wBWfCz/cKkOpAcumsETRkFAl70sWi8ItcMEsM=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.11/go.mod h1:be1NIO30kJA23ORBLqPo1LttEM6tPNSEcjkd1eKzNW0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 h1:5oE2WzJE56/mVveuDZPJESKlg/00AaS2pY2QZcnxg4M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10/go.mod h1:FHbKWQtRBYUz4vO5WBWjzMD2by126ny5y/1EoaWoLfI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 h1:L0ai8WICYHozIKK+OtPzVJBugL7culcuM4E4JOpIEm8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10/go.mod h1:byqfyxJBshFk0fF9YmK0M0ugIO8OWjzH2T3bPG4eGuA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 h1:KOxnQeWy5sXyS37fdKEvAsGHOr9fa/qvwxfJurR/BzE=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10/go.mod h1:jMx5INQFYFYB3lQD9W0D8Ohgq6Wnl7NYOJ2TQndbulI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.8 h1:vPmag9qVmGho0jvtK5+nLwixJeX6Smd0IZE1OJIQ7wE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.8/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0 h1:PJTdBMsyvra6FtED7JZtDpQrIAflYDHFoZAu/sKYkwU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.6 h1:dGrs+Q/WzhsiUKh82SfTVN66QzyulXuMDTV/G8ZxOac=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.6/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.6 h1:Yf2MIo9x+0tyv76GljxzqA3WtC5mw7NmazD2chwjxE4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.6/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/aws/aws-sdk-go v1.51.11 h1:El5VypsMIz7sFwAAj/j06JX9UGs4KAbAIEaZ57bNY4s=
github.com/aws/aws-sdk-go v1.51.11/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.8 h1:u1KOU1S15ufyZqmH/rA3POkiRH6EcDANHj2xHRzq+zc=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.8/go.mod h1:WPv2FRnkIOoDv/8j2gSUsI4qDc7392w5anFB/I89GZ8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15/go.mod h1:CetW7bDE00QoGEmPUoZuRog07SGVAUVW6LFpNP0YfIg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 h1:YPYe6ZmvUfDDDELqEKtAd6bo8zxhkm+XEFEzQisqUIE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17/go.mod h1:oBtcnYua/CgzCWYN7NZ5j7PotFDaFSUjCYVTtfyn7vw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 h1:246A4lSTXWJw/rmlQI+TT2OcqeDMKBdyjEQrafMaQdA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15/go.mod h1:haVfg3761/WF7YPuJOER2MP0k4UAXyHaLclKXB6usDg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2 h1:sZXIzO38GZOU+O0C+INqbH7C2yALwfMWpd64tONS/NE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2/go.mod h1:Lcxzg5rojyVPU/0eFwLtcyTaek/6Mtic5B1gJo7e/zE=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -110,44 +73,45 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c h1:hLoodLRD4KLWIH8eyAQCLcH8EqIrjac7fCkp/fHnvuQ=
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c/go.mod h1:bhGPmCgCCTSRfiMYWjpS46IDo9EUZXlsuUaPXSWGbv0=
github.com/dop251/goja v0.0.0-20240627195025-eb1f15ee67d2 h1:4Ew88p5s9dwIk5/woUyqI9BD89NgZoUNH4/rM/h2UDg=
github.com/dop251/goja v0.0.0-20240627195025-eb1f15ee67d2/go.mod h1:o31y53rb/qiIAONF7w3FHJZRqqP3fzHUr1HqanthByw=
github.com/dop251/goja_nodejs v0.0.0-20240418154818-2aae10d4cbcf h1:2JoVYP9iko8uuIW33BQafzaylDixXbdXCRw/vCoxL+s=
github.com/dop251/goja_nodejs v0.0.0-20240418154818-2aae10d4cbcf/go.mod h1:bhGPmCgCCTSRfiMYWjpS46IDo9EUZXlsuUaPXSWGbv0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/ganigeorgiev/fexpr v0.4.0 h1:ojitI+VMNZX/odeNL1x3RzTTE8qAIVvnSSYPNAnQFDI=
github.com/ganigeorgiev/fexpr v0.4.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4=
github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -165,60 +129,41 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-replayers/grpcreplay v1.1.0 h1:S5+I3zYyZ+GQz68OfbURDdt/+cSMqCK1wrvNx7WBzTE=
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg=
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
@@ -230,32 +175,29 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4 h1:85kAYIKrKEeau7WgXg8B7Km8etrVavJAyH7XcR5MkFw=
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4/go.mod h1:dOJ+pCyqm/jRn5kO/TX598J0e5xGDcJAZerK5atCrKI=
github.com/pocketbase/tygoja v0.0.0-20240113091827-17918475d342 h1:OcAwewen3hs/zY8i0syt8CcMTGBJhQwQRVDLcoQVXVk=
github.com/pocketbase/tygoja v0.0.0-20240113091827-17918475d342/go.mod h1:dOJ+pCyqm/jRn5kO/TX598J0e5xGDcJAZerK5atCrKI=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -275,31 +217,33 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
gocloud.dev v0.35.0 h1:x/Gtt5OJdT4j+ir1AXAIXb7bBnFawXAAaJptCUGk3HU=
gocloud.dev v0.35.0/go.mod h1:wbyF+BhfdtLWyUtVEWRW13hFLb1vXnV2ovEhYGQe3ck=
gocloud.dev v0.36.0 h1:q5zoXux4xkOZP473e1EZbG8Gq9f0vlg1VNH5Du/ybus=
gocloud.dev v0.36.0/go.mod h1:bLxah6JQVKBaIxzsr5BQLYB4IYdWHkMZdzCXlo6F0gg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro=
gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -310,23 +254,17 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -334,85 +272,59 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.153.0 h1:N1AwGhielyKFaUqH07/ZSIQR3uNPcV7NVw0vj+j4iR4=
google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY=
google.golang.org/api v0.155.0 h1:vBmGhCYs0djJttDNynWo44zosHlPvHmA0XiN2zP2DtA=
google.golang.org/api v0.155.0/go.mod h1:GI5qK5f40kCpHfPn6+YzGAByIKWv8ujFnmoWm7Igduk=
google.golang.org/api v0.156.0 h1:yloYcGbBtVYjLKQe4enCunxvwn3s2w/XPrrhVf6MsvQ=
google.golang.org/api v0.156.0/go.mod h1:bUSmn4KFO0Q+69zo9CNIDp4Psi6BqM0np0CbzKRSiSY=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f h1:Vn+VyHU5guc9KjB5KrjI2q0wCOWEOIh0OEsleqakHJg=
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI=
google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3 h1:EWIeHfGuUf00zrVZGEgYFxok7plSAXBGcH7NNdMAWvA=
google.golang.org/genproto/googleapis/api v0.0.0-20231212172506-995d672761c0 h1:s1w3X6gQxwrLEpxnLd/qXTVLgQE2yXwaOaoa6IlY/+o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1 h1:gphdwh0npgs8elJ4T6J+DQJHPVF7RsuJHCfwztUb4J4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a h1:hqK4+jJZXCU4pW7jsAdGOVFIfLHQeV7LaizZKnZ84HI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240723171418-e6d459c13d2a/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU=
google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -422,18 +334,10 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -441,37 +345,29 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.37.0 h1:WerjebcsP6A7Jy+f2lCnHAkiSTLf7IaSftBYUtoswak=
modernc.org/libc v1.37.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/libc v1.38.0 h1:o4Lpk0zNDSdsjfEXnF1FGXWQ9PDi1NOdWcLP5n13FGo=
modernc.org/libc v1.38.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/libc v1.40.1 h1:ZhRylEBcj3GyQbPVC8JxIg7SdrT4JOxIDJoUon0NfF8=
modernc.org/libc v1.40.1/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e h1:WPC4v0rNIFb2PY+nBBEEKyugPPRHPzUgyN3xZPpGK58=
modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.31.1 h1:XVU0VyzxrYHlBhIs1DiEgSl0ZtdnPtbLVy8hSkzxGrs=
modernc.org/sqlite v1.31.1/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
+38 -1
View File
@@ -12,6 +12,43 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer"
)
// @todo remove after the refactoring
//
// SendRecordPasswordLoginAlert sends a OAuth2 password login alert to the specified auth record.
func SendRecordPasswordLoginAlert(app core.App, authRecord *models.Record, providerNames ...string) error {
params := struct {
AppName string
AppUrl string
Record *models.Record
ProviderNames []string
}{
AppName: app.Settings().Meta.AppName,
AppUrl: app.Settings().Meta.AppUrl,
Record: authRecord,
ProviderNames: providerNames,
}
mailClient := app.NewMailClient()
// resolve body template
body, renderErr := resolveTemplateContent(params, templates.Layout, templates.PasswordLoginAlertBody)
if renderErr != nil {
return renderErr
}
message := &mailer.Message{
From: mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: []mail.Address{{Address: authRecord.Email()}},
Subject: "Password login alert",
HTML: body,
}
return mailClient.Send(message)
}
// SendRecordPasswordReset sends a password reset request email to the specified user.
func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
token, tokenErr := tokens.NewRecordResetPasswordToken(app, authRecord)
@@ -92,7 +129,7 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
})
}
// SendUserChangeEmail sends a change email confirmation email to the specified user.
// SendRecordChangeEmail sends a change email confirmation email to the specified user.
func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string) error {
token, tokenErr := tokens.NewRecordChangeEmailToken(app, record, newEmail)
if tokenErr != nil {
+29
View File
@@ -8,6 +8,35 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestSendRecordPasswordLoginAlert(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// ensure that action url normalization will be applied
testApp.Settings().Meta.AppUrl = "http://localhost:8090////"
user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
err := mails.SendRecordPasswordLoginAlert(testApp, user, "test1", "test2")
if err != nil {
t.Fatal(err)
}
if testApp.TestMailer.TotalSend != 1 {
t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend)
}
expectedParts := []string{"using a password", "OAuth2", "test1", "test2", "auth linked"}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
t.Fatalf("Couldn't find %s\n in\n %s", part, testApp.TestMailer.LastMessage.HTML)
}
}
}
func TestSendRecordPasswordReset(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -53,7 +53,7 @@ const Layout = `
.btn {
display: inline-block;
vertical-align: top;
border: 0;
border: 1px solid #e5e5e5;
cursor: pointer;
color: #fff !important;
background: #16161a !important;
+30
View File
@@ -0,0 +1,30 @@
package templates
// Available variables:
//
// ```
// Record *models.Record
// AppName string
// AppUrl string
// ProviderNames []string
// ```
const PasswordLoginAlertBody = `
{{define "content"}}
<p>Hello,</p>
<p>
Just to let you know that someone has logged in to your {{.AppName}} account using a password while you already have
OAuth2
{{range $index, $provider := .ProviderNames }}
{{if $index}}|{{end}}
{{ $provider }}
{{ end }}
auth linked.
</p>
<p>If you have recently signed in with a password, you may disregard this email.</p>
<p><strong>If you don't recognize the above action, you should immediately change your {{.AppName}} account password.</strong></p>
<p>
Thanks,<br/>
{{.AppName}} team
</p>
{{end}}
`
@@ -0,0 +1,56 @@
package migrations
import (
"slices"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/security"
)
// adds a "lastLoginAlertSentAt" column to all auth collection tables (if not already)
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
err := dao.CollectionQuery().AndWhere(dbx.HashExp{"type": models.CollectionTypeAuth}).All(&collections)
if err != nil {
return err
}
var needToResetTokens bool
for _, c := range collections {
columns, err := dao.TableColumns(c.Name)
if err != nil {
return err
}
if slices.Contains(columns, schema.FieldNameLastLoginAlertSentAt) {
continue // already inserted
}
_, err = db.AddColumn(c.Name, schema.FieldNameLastLoginAlertSentAt, "TEXT DEFAULT '' NOT NULL").Execute()
if err != nil {
return err
}
opts := c.AuthOptions()
if opts.AllowOAuth2Auth && (opts.AllowEmailAuth || opts.AllowUsernameAuth) {
needToResetTokens = true
}
}
settings, _ := dao.FindSettings()
if needToResetTokens && settings != nil {
settings.RecordAuthToken.Secret = security.RandomString(50)
if err := dao.SaveSettings(settings); err != nil {
return err
}
}
return nil
}, nil)
}
+21 -3
View File
@@ -309,7 +309,7 @@ func (m *Record) Set(key string, value any) {
switch key {
case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
v = cast.ToBool(value)
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt, schema.FieldNameLastLoginAlertSentAt:
v, _ = types.ParseDateTime(value)
case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
v = cast.ToString(value)
@@ -347,7 +347,7 @@ func (m *Record) Get(key string) any {
switch key {
case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
v = cast.ToBool(v)
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt, schema.FieldNameLastLoginAlertSentAt:
v, _ = types.ParseDateTime(v)
case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
v = cast.ToString(v)
@@ -685,7 +685,7 @@ func (m *Record) getNormalizeDataValueForDB(key string) any {
switch key {
case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
return m.GetBool(key)
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt, schema.FieldNameLastLoginAlertSentAt:
return m.GetDateTime(key)
case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
return m.GetString(key)
@@ -898,6 +898,24 @@ func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error {
return nil
}
// LastLoginAlertSentAt returns the "lastLoginAlertSentAt" auth record data value.
func (m *Record) LastLoginAlertSentAt() types.DateTime {
return m.GetDateTime(schema.FieldNameLastLoginAlertSentAt)
}
// SetLastLoginAlertSentAt sets an "lastLoginAlertSentAt" auth record data value.
//
// Returns an error if the record is not from an auth collection.
func (m *Record) SetLastLoginAlertSentAt(dateTime types.DateTime) error {
if !m.collection.IsAuth() {
return notAuthRecordErr
}
m.Set(schema.FieldNameLastLoginAlertSentAt, dateTime)
return nil
}
// PasswordHash returns the "passwordHash" auth record data value.
func (m *Record) PasswordHash() string {
return m.GetString(schema.FieldNamePasswordHash)
+47 -1
View File
@@ -1328,7 +1328,7 @@ func TestRecordColumnValueMap(t *testing.T) {
},
{
models.CollectionTypeAuth,
`{"created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","updated":"","username":"test_username","verified":false}`,
`{"created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id","lastLoginAlertSentAt":"","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","updated":"","username":"test_username","verified":false}`,
},
}
@@ -1975,6 +1975,52 @@ func TestRecordRefreshTokenKey(t *testing.T) {
}
}
func TestRecordLastPasswordLoginAlertSentAt(t *testing.T) {
t.Parallel()
scenarios := []struct {
collectionType string
expectError bool
}{
{models.CollectionTypeBase, true},
{models.CollectionTypeAuth, false},
}
testValue, err := types.ParseDateTime("2022-01-01 00:00:00.123Z")
if err != nil {
t.Fatal(err)
}
for i, s := range scenarios {
collection := &models.Collection{Type: s.collectionType}
m := models.NewRecord(collection)
if s.expectError {
if err := m.SetLastLoginAlertSentAt(testValue); err == nil {
t.Errorf("(%d) Expected error, got nil", i)
}
if v := m.LastLoginAlertSentAt(); !v.IsZero() {
t.Fatalf("(%d) Expected empty value, got %v", i, v)
}
// verify that nothing is stored in the record data slice
if v := m.Get(schema.FieldNameLastLoginAlertSentAt); v != nil {
t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameLastLoginAlertSentAt, v)
}
} else {
if err := m.SetLastLoginAlertSentAt(testValue); err != nil {
t.Fatalf("(%d) Expected nil, got error %v", i, err)
}
if v := m.LastLoginAlertSentAt(); v != testValue {
t.Fatalf("(%d) Expected %v, got %v", i, testValue, v)
}
// verify that the field is stored in the record data slice
if v := m.Get(schema.FieldNameLastLoginAlertSentAt); v != testValue {
t.Fatalf("(%d) Expected data field value %v, got %v", i, testValue, v)
}
}
}
}
func TestRecordLastResetSentAt(t *testing.T) {
t.Parallel()
+8
View File
@@ -6,9 +6,17 @@ import (
"github.com/pocketbase/pocketbase/models/schema"
)
const (
RequestInfoContextDefault = "default"
RequestInfoContextRealtime = "realtime"
RequestInfoContextProtectedFile = "protectedFile"
RequestInfoContextOAuth2 = "oauth2"
)
// RequestInfo defines a HTTP request data struct, usually used
// as part of the `@request.*` filter resolver.
type RequestInfo struct {
Context string `json:"context"`
Query map[string]any `json:"query"`
Data map[string]any `json:"data"`
Headers map[string]any `json:"headers"`
+18
View File
@@ -5,6 +5,7 @@ import (
"errors"
"regexp"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
@@ -46,6 +47,7 @@ const (
FieldNamePasswordHash string = "passwordHash"
FieldNameLastResetSentAt string = "lastResetSentAt"
FieldNameLastVerificationSentAt string = "lastVerificationSentAt"
FieldNameLastLoginAlertSentAt string = "lastLoginAlertSentAt"
)
// BaseModelFieldNames returns the field names that all models have (id, created, updated).
@@ -77,6 +79,7 @@ func AuthFieldNames() []string {
FieldNamePasswordHash,
FieldNameLastResetSentAt,
FieldNameLastVerificationSentAt,
FieldNameLastLoginAlertSentAt,
}
}
@@ -211,6 +214,7 @@ func (f SchemaField) Validate() error {
validation.Length(1, 255),
validation.Match(schemaFieldNameRegex),
validation.NotIn(list.ToInterfaceSlice(excludeNames)...),
validation.By(f.checkForVia),
),
validation.Field(&f.Type, validation.Required, validation.In(list.ToInterfaceSlice(FieldTypes())...)),
// currently file fields cannot be unique because a proper
@@ -228,6 +232,20 @@ func (f *SchemaField) checkOptions(value any) error {
return v.Validate()
}
// @todo merge with the collections during the refactoring
func (f *SchemaField) checkForVia(value any) error {
v, _ := value.(string)
if v == "" {
return nil
}
if strings.Contains(strings.ToLower(v), "_via_") {
return validation.NewError("validation_invalid_name", "The name of the field cannot contain '_via_'.")
}
return nil
}
// InitOptions initializes the current field options based on its type.
//
// Returns error on unknown field type.
+10 -1
View File
@@ -31,7 +31,7 @@ func TestSystemFieldNames(t *testing.T) {
func TestAuthFieldNames(t *testing.T) {
result := schema.AuthFieldNames()
expected := 8
expected := 9
if len(result) != expected {
t.Fatalf("Expected %d auth field names, got %d (%v)", expected, len(result), result)
@@ -298,6 +298,15 @@ func TestSchemaFieldValidate(t *testing.T) {
},
[]string{"name"},
},
{
"name with _via_",
schema.SchemaField{
Type: schema.FieldTypeText,
Id: "1234567890",
Name: "a_via_b",
},
[]string{"name"},
},
{
"reserved name (null)",
schema.SchemaField{
+63 -48
View File
@@ -42,29 +42,31 @@ type Settings struct {
// Deprecated: Will be removed in v0.9+
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
GitlabAuth AuthProviderConfig `form:"gitlabAuth" json:"gitlabAuth"`
DiscordAuth AuthProviderConfig `form:"discordAuth" json:"discordAuth"`
TwitterAuth AuthProviderConfig `form:"twitterAuth" json:"twitterAuth"`
MicrosoftAuth AuthProviderConfig `form:"microsoftAuth" json:"microsoftAuth"`
SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"`
KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"`
TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
LivechatAuth AuthProviderConfig `form:"livechatAuth" json:"livechatAuth"`
GiteaAuth AuthProviderConfig `form:"giteaAuth" json:"giteaAuth"`
OIDCAuth AuthProviderConfig `form:"oidcAuth" json:"oidcAuth"`
OIDC2Auth AuthProviderConfig `form:"oidc2Auth" json:"oidc2Auth"`
OIDC3Auth AuthProviderConfig `form:"oidc3Auth" json:"oidc3Auth"`
AppleAuth AuthProviderConfig `form:"appleAuth" json:"appleAuth"`
InstagramAuth AuthProviderConfig `form:"instagramAuth" json:"instagramAuth"`
VKAuth AuthProviderConfig `form:"vkAuth" json:"vkAuth"`
YandexAuth AuthProviderConfig `form:"yandexAuth" json:"yandexAuth"`
PatreonAuth AuthProviderConfig `form:"patreonAuth" json:"patreonAuth"`
MailcowAuth AuthProviderConfig `form:"mailcowAuth" json:"mailcowAuth"`
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
GitlabAuth AuthProviderConfig `form:"gitlabAuth" json:"gitlabAuth"`
DiscordAuth AuthProviderConfig `form:"discordAuth" json:"discordAuth"`
TwitterAuth AuthProviderConfig `form:"twitterAuth" json:"twitterAuth"`
MicrosoftAuth AuthProviderConfig `form:"microsoftAuth" json:"microsoftAuth"`
SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"`
KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"`
TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
LivechatAuth AuthProviderConfig `form:"livechatAuth" json:"livechatAuth"`
GiteaAuth AuthProviderConfig `form:"giteaAuth" json:"giteaAuth"`
OIDCAuth AuthProviderConfig `form:"oidcAuth" json:"oidcAuth"`
OIDC2Auth AuthProviderConfig `form:"oidc2Auth" json:"oidc2Auth"`
OIDC3Auth AuthProviderConfig `form:"oidc3Auth" json:"oidc3Auth"`
AppleAuth AuthProviderConfig `form:"appleAuth" json:"appleAuth"`
InstagramAuth AuthProviderConfig `form:"instagramAuth" json:"instagramAuth"`
VKAuth AuthProviderConfig `form:"vkAuth" json:"vkAuth"`
YandexAuth AuthProviderConfig `form:"yandexAuth" json:"yandexAuth"`
PatreonAuth AuthProviderConfig `form:"patreonAuth" json:"patreonAuth"`
MailcowAuth AuthProviderConfig `form:"mailcowAuth" json:"mailcowAuth"`
BitbucketAuth AuthProviderConfig `form:"bitbucketAuth" json:"bitbucketAuth"`
PlanningcenterAuth AuthProviderConfig `form:"planningcenterAuth" json:"planningcenterAuth"`
}
// New creates and returns a new default Settings instance.
@@ -196,6 +198,12 @@ func New() *Settings {
MailcowAuth: AuthProviderConfig{
Enabled: false,
},
BitbucketAuth: AuthProviderConfig{
Enabled: false,
},
PlanningcenterAuth: AuthProviderConfig{
Enabled: false,
},
}
}
@@ -241,6 +249,8 @@ func (s *Settings) Validate() error {
validation.Field(&s.YandexAuth),
validation.Field(&s.PatreonAuth),
validation.Field(&s.MailcowAuth),
validation.Field(&s.BitbucketAuth),
validation.Field(&s.PlanningcenterAuth),
)
}
@@ -309,6 +319,8 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.YandexAuth.ClientSecret,
&clone.PatreonAuth.ClientSecret,
&clone.MailcowAuth.ClientSecret,
&clone.BitbucketAuth.ClientSecret,
&clone.PlanningcenterAuth.ClientSecret,
}
// mask all sensitive fields
@@ -328,29 +340,31 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
defer s.mux.RUnlock()
return map[string]AuthProviderConfig{
auth.NameGoogle: s.GoogleAuth,
auth.NameFacebook: s.FacebookAuth,
auth.NameGithub: s.GithubAuth,
auth.NameGitlab: s.GitlabAuth,
auth.NameDiscord: s.DiscordAuth,
auth.NameTwitter: s.TwitterAuth,
auth.NameMicrosoft: s.MicrosoftAuth,
auth.NameSpotify: s.SpotifyAuth,
auth.NameKakao: s.KakaoAuth,
auth.NameTwitch: s.TwitchAuth,
auth.NameStrava: s.StravaAuth,
auth.NameGitee: s.GiteeAuth,
auth.NameLivechat: s.LivechatAuth,
auth.NameGitea: s.GiteaAuth,
auth.NameOIDC: s.OIDCAuth,
auth.NameOIDC + "2": s.OIDC2Auth,
auth.NameOIDC + "3": s.OIDC3Auth,
auth.NameApple: s.AppleAuth,
auth.NameInstagram: s.InstagramAuth,
auth.NameVK: s.VKAuth,
auth.NameYandex: s.YandexAuth,
auth.NamePatreon: s.PatreonAuth,
auth.NameMailcow: s.MailcowAuth,
auth.NameGoogle: s.GoogleAuth,
auth.NameFacebook: s.FacebookAuth,
auth.NameGithub: s.GithubAuth,
auth.NameGitlab: s.GitlabAuth,
auth.NameDiscord: s.DiscordAuth,
auth.NameTwitter: s.TwitterAuth,
auth.NameMicrosoft: s.MicrosoftAuth,
auth.NameSpotify: s.SpotifyAuth,
auth.NameKakao: s.KakaoAuth,
auth.NameTwitch: s.TwitchAuth,
auth.NameStrava: s.StravaAuth,
auth.NameGitee: s.GiteeAuth,
auth.NameLivechat: s.LivechatAuth,
auth.NameGitea: s.GiteaAuth,
auth.NameOIDC: s.OIDCAuth,
auth.NameOIDC + "2": s.OIDC2Auth,
auth.NameOIDC + "3": s.OIDC3Auth,
auth.NameApple: s.AppleAuth,
auth.NameInstagram: s.InstagramAuth,
auth.NameVK: s.VKAuth,
auth.NameYandex: s.YandexAuth,
auth.NamePatreon: s.PatreonAuth,
auth.NameMailcow: s.MailcowAuth,
auth.NameBitbucket: s.BitbucketAuth,
auth.NamePlanningcenter: s.PlanningcenterAuth,
}
}
@@ -449,7 +463,7 @@ type BackupsConfig struct {
// Leave it empty to disable the auto backups functionality.
Cron string `form:"cron" json:"cron"`
// CronMaxKeep is the the max number of cron generated backups to
// CronMaxKeep is the max number of cron generated backups to
// keep before removing older entries.
//
// This field works only when the cron config has valid cron expression.
@@ -516,6 +530,7 @@ type EmailTemplate struct {
Body string `form:"body" json:"body"`
Subject string `form:"subject" json:"subject"`
ActionUrl string `form:"actionUrl" json:"actionUrl"`
Hidden bool `form:"hidden" json:"hidden"`
}
// Validate makes EmailTemplate validatable by implementing [validation.Validatable] interface.
@@ -638,7 +653,7 @@ func (c AuthProviderConfig) Validate() error {
// SetupProvider loads the current AuthProviderConfig into the specified provider.
func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
if !c.Enabled {
return errors.New("The provider is not enabled.")
return errors.New("the provider is not enabled")
}
if c.ClientId != "" {
+16
View File
@@ -78,6 +78,10 @@ func TestSettingsValidate(t *testing.T) {
s.PatreonAuth.ClientId = ""
s.MailcowAuth.Enabled = true
s.MailcowAuth.ClientId = ""
s.BitbucketAuth.Enabled = true
s.BitbucketAuth.ClientId = ""
s.PlanningcenterAuth.Enabled = true
s.PlanningcenterAuth.ClientId = ""
// check if Validate() is triggering the members validate methods.
err := s.Validate()
@@ -121,6 +125,8 @@ func TestSettingsValidate(t *testing.T) {
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
}
errBytes, _ := json.Marshal(err)
@@ -198,6 +204,10 @@ func TestSettingsMerge(t *testing.T) {
s2.PatreonAuth.ClientId = "patreon_test"
s2.MailcowAuth.Enabled = true
s2.MailcowAuth.ClientId = "mailcow_test"
s2.BitbucketAuth.Enabled = true
s2.BitbucketAuth.ClientId = "bitbucket_test"
s2.PlanningcenterAuth.Enabled = true
s2.PlanningcenterAuth.ClientId = "planningcenter_test"
if err := s1.Merge(s2); err != nil {
t.Fatal(err)
@@ -290,6 +300,8 @@ func TestSettingsRedactClone(t *testing.T) {
s1.YandexAuth.ClientSecret = testSecret
s1.PatreonAuth.ClientSecret = testSecret
s1.MailcowAuth.ClientSecret = testSecret
s1.BitbucketAuth.ClientSecret = testSecret
s1.PlanningcenterAuth.ClientSecret = testSecret
s1Bytes, err := json.Marshal(s1)
if err != nil {
@@ -350,6 +362,8 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
s.YandexAuth.ClientId = "yandex_test"
s.PatreonAuth.ClientId = "patreon_test"
s.MailcowAuth.ClientId = "mailcow_test"
s.BitbucketAuth.ClientId = "bitbucket_test"
s.PlanningcenterAuth.ClientId = "planningcenter_test"
result := s.NamedAuthProviderConfigs()
@@ -383,6 +397,8 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
`"yandex":{"enabled":false,"clientId":"yandex_test"`,
`"patreon":{"enabled":false,"clientId":"patreon_test"`,
`"mailcow":{"enabled":false,"clientId":"mailcow_test"`,
`"bitbucket":{"enabled":false,"clientId":"bitbucket_test"`,
`"planningcenter":{"enabled":false,"clientId":"planningcenter_test"`,
}
for _, p := range expectedParts {
if !strings.Contains(encodedStr, p) {
+4 -4
View File
@@ -97,8 +97,8 @@ func Register(app core.App, rootCmd *cobra.Command, config Config) error {
type plugin struct {
app core.App
currentVersion string
config Config
currentVersion string
}
func (p *plugin) updateCmd() *cobra.Command {
@@ -106,7 +106,7 @@ func (p *plugin) updateCmd() *cobra.Command {
command := &cobra.Command{
Use: "update",
Short: "Automatically updates the current PocketBase executable with the latest available version",
Short: "Automatically updates the current app executable with the latest available version",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
var needConfirm bool
@@ -160,7 +160,7 @@ func (p *plugin) update(withBackup bool) error {
}
if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
color.Green("You already have the latest PocketBase %s.", p.currentVersion)
color.Green("You already have the latest version %s.", p.currentVersion)
return nil
}
@@ -252,7 +252,7 @@ func (p *plugin) update(withBackup bool) error {
fmt.Print("\n")
color.Cyan("Here is a list with some of the %s changes:", latest.Tag)
// remove the update command note to avoid "stuttering"
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./pocketbase update`._", "", 1))
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
color.Cyan(releaseNotes)
fmt.Print("\n")
}
+1
View File
@@ -21,6 +21,7 @@ func TestCompareVersions(t *testing.T) {
{"0.0.2", "0.0.1", -1},
{"0.16.2", "0.17.0", 1},
{"1.15.0", "0.16.1", -1},
{"1.2.9", "1.2.10", 1},
{"3.2", "4.0", 1},
{"3.2.4", "3.2.3", -1},
}
+78 -19
View File
@@ -18,6 +18,7 @@ import (
"github.com/dop251/goja"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -37,6 +38,7 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"github.com/spf13/cobra"
)
@@ -121,7 +123,7 @@ func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
})
if err != nil {
app.Logger().Debug(
app.Logger().Error(
"[cronAdd] failed to execute cron job",
slog.String("jobId", jobId),
slog.String("error", err.Error()),
@@ -375,7 +377,7 @@ func baseBinds(vm *goja.Runtime) {
})
vm.Set("RequestInfo", func(call goja.ConstructorCall) *goja.Object {
instance := &models.RequestInfo{}
instance := &models.RequestInfo{Context: models.RequestInfoContextDefault}
return structConstructor(vm, call, instance)
})
@@ -533,6 +535,16 @@ func filesystemBinds(vm *goja.Runtime) {
obj.Set("fileFromPath", filesystem.NewFileFromPath)
obj.Set("fileFromBytes", filesystem.NewFileFromBytes)
obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart)
obj.Set("fileFromUrl", func(url string, secTimeout int) (*filesystem.File, error) {
if secTimeout == 0 {
secTimeout = 120
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(secTimeout)*time.Second)
defer cancel()
return filesystem.NewFileFromUrl(ctx, url)
})
}
func filepathBinds(vm *goja.Runtime) {
@@ -618,6 +630,8 @@ func apisBinds(vm *goja.Runtime) {
obj.Set("requireAdminOrRecordAuth", apis.RequireAdminOrRecordAuth)
obj.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth)
obj.Set("activityLogger", apis.ActivityLogger)
obj.Set("gzip", middleware.Gzip)
obj.Set("bodyLimit", middleware.BodyLimit)
// record helpers
obj.Set("requestInfo", apis.RequestInfo)
@@ -637,34 +651,61 @@ func httpClientBinds(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$http", obj)
vm.Set("FormData", func(call goja.ConstructorCall) *goja.Object {
instance := FormData{}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
type sendResult struct {
StatusCode int `json:"statusCode"`
Json any `json:"json"`
Headers map[string][]string `json:"headers"`
Cookies map[string]*http.Cookie `json:"cookies"`
Raw string `json:"raw"`
Json any `json:"json"`
StatusCode int `json:"statusCode"`
}
type sendConfig struct {
// Deprecated: consider using Body instead
Data map[string]any
Body any // raw string or FormData
Headers map[string]string
Method string
Url string
Body string
Headers map[string]string
Timeout int // seconds (default to 120)
Data map[string]any // deprecated, consider using Body instead
Timeout int // seconds (default to 120)
}
obj.Set("send", func(params map[string]any) (*sendResult, error) {
rawParams, err := json.Marshal(params)
if err != nil {
return nil, err
}
config := sendConfig{
Method: "GET",
}
if err := json.Unmarshal(rawParams, &config); err != nil {
return nil, err
if v, ok := params["data"]; ok {
config.Data = cast.ToStringMap(v)
}
if v, ok := params["body"]; ok {
config.Body = v
}
if v, ok := params["headers"]; ok {
config.Headers = cast.ToStringMapString(v)
}
if v, ok := params["method"]; ok {
config.Method = cast.ToString(v)
}
if v, ok := params["url"]; ok {
config.Url = cast.ToString(v)
}
if v, ok := params["timeout"]; ok {
config.Timeout = cast.ToInt(v)
}
if config.Timeout <= 0 {
@@ -675,6 +716,7 @@ func httpClientBinds(vm *goja.Runtime) {
defer cancel()
var reqBody io.Reader
var contentType string
// legacy json body data
if len(config.Data) != 0 {
@@ -683,10 +725,19 @@ func httpClientBinds(vm *goja.Runtime) {
return nil, err
}
reqBody = bytes.NewReader(encoded)
}
} else {
switch v := config.Body.(type) {
case FormData:
body, mp, err := v.toMultipart()
if err != nil {
return nil, err
}
if config.Body != "" {
reqBody = strings.NewReader(config.Body)
reqBody = body
contentType = mp.FormDataContentType()
default:
reqBody = strings.NewReader(cast.ToString(config.Body))
}
}
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(config.Method), config.Url, reqBody)
@@ -698,7 +749,15 @@ func httpClientBinds(vm *goja.Runtime) {
req.Header.Add(k, v)
}
// set default content-type header (if missing)
// set the explicit content type
// (overwriting the user provided header value if any)
if contentType != "" {
req.Header.Set("content-type", contentType)
}
// @todo consider removing during the refactoring
//
// fallback to json content-type
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
}
+71 -4
View File
@@ -2,6 +2,7 @@ package jsvm
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
@@ -890,13 +891,23 @@ func TestFilesystemBinds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/error" {
w.WriteHeader(http.StatusInternalServerError)
}
fmt.Fprintf(w, "test")
}))
defer srv.Close()
vm := goja.New()
vm.Set("mh", &multipart.FileHeader{Filename: "test"})
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
vm.Set("baseUrl", srv.URL)
baseBinds(vm)
filesystemBinds(vm)
testBindsCount(vm, "$filesystem", 3, t)
testBindsCount(vm, "$filesystem", 4, t)
// fileFromPath
{
@@ -939,6 +950,28 @@ func TestFilesystemBinds(t *testing.T) {
t.Fatalf("[fileFromMultipart] Expected file with name %q, got %v", file.OriginalName, file)
}
}
// fileFromUrl (success)
{
v, err := vm.RunString(`$filesystem.fileFromUrl(baseUrl + "/test")`)
if err != nil {
t.Fatal(err)
}
file, _ := v.Export().(*filesystem.File)
if file == nil || file.OriginalName != "test" {
t.Fatalf("[fileFromUrl] Expected file with name %q, got %v", file.OriginalName, file)
}
}
// fileFromUrl (failure)
{
_, err := vm.RunString(`$filesystem.fileFromUrl(baseUrl + "/error")`)
if err == nil {
t.Fatal("Expected url fetch error")
}
}
}
func TestFormsBinds(t *testing.T) {
@@ -956,7 +989,7 @@ func TestApisBindsCount(t *testing.T) {
apisBinds(vm)
testBindsCount(vm, "this", 6, t)
testBindsCount(vm, "$apis", 12, t)
testBindsCount(vm, "$apis", 14, t)
}
func TestApisBindsApiError(t *testing.T) {
@@ -1121,6 +1154,7 @@ func TestHttpClientBindsCount(t *testing.T) {
vm := goja.New()
httpClientBinds(vm)
testBindsCount(vm, "this", 2, t) // + FormData
testBindsCount(vm, "$http", 1, t)
}
@@ -1223,6 +1257,15 @@ func TestHttpClientBindsSend(t *testing.T) {
headers: {"content-type": "text/plain"},
})
// with FormData
const formData = new FormData()
formData.append("title", "123")
const test3 = $http.send({
url: testUrl,
body: formData,
headers: {"content-type": "text/plain"}, // should be ignored
})
const scenarios = [
[test0, {
"statusCode": "400",
@@ -1244,6 +1287,18 @@ func TestHttpClientBindsSend(t *testing.T) {
"json.method": "GET",
"json.headers.content_type": "text/plain",
}],
[test3, {
"statusCode": "200",
"headers.X-Custom.0": "custom_header",
"cookies.sessionId.value": "123456",
"json.method": "GET",
"json.body": [
"\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n123\r\n--",
],
"json.headers.content_type": [
"multipart/form-data; boundary="
],
}],
]
for (let scenario of scenarios) {
@@ -1251,8 +1306,20 @@ func TestHttpClientBindsSend(t *testing.T) {
const expectations = scenario[1];
for (let key in expectations) {
if (getNestedVal(result, key) != expectations[key]) {
throw new Error('Expected ' + key + ' ' + expectations[key] + ', got: ' + result.raw);
const value = getNestedVal(result, key);
const expectation = expectations[key]
if (Array.isArray(expectation)) {
// check for partial match(es)
for (let exp of expectation) {
if (!value.includes(exp)) {
throw new Error('Expected ' + key + ' to contain ' + exp + ', got: ' + result.raw);
}
}
} else {
// check for direct match
if (value != expectation) {
throw new Error('Expected ' + key + ' ' + expectation + ', got: ' + result.raw);
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
package jsvm
import (
"bytes"
"io"
"mime/multipart"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/spf13/cast"
)
// FormData represents an interface similar to the browser's [FormData].
//
// The value of each FormData entry must be a string or [*filesystem.File] instance.
//
// It is intended to be used together with the JSVM `$http.send` when
// sending multipart/form-data requests.
//
// [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData.
type FormData map[string][]any
// Append appends a new value onto an existing key inside the current FormData,
// or adds the key if it does not already exist.
func (data FormData) Append(key string, value any) {
data[key] = append(data[key], value)
}
// Set sets a new value for an existing key inside the current FormData,
// or adds the key/value if it does not already exist.
func (data FormData) Set(key string, value any) {
data[key] = []any{value}
}
// Delete deletes a key and its value(s) from the current FormData.
func (data FormData) Delete(key string) {
delete(data, key)
}
// Get returns the first value associated with a given key from
// within the current FormData.
//
// If you expect multiple values and want all of them,
// use the [FormData.GetAll] method instead.
func (data FormData) Get(key string) any {
values, ok := data[key]
if !ok || len(values) == 0 {
return nil
}
return values[0]
}
// GetAll returns all the values associated with a given key
// from within the current FormData.
func (data FormData) GetAll(key string) []any {
values, ok := data[key]
if !ok {
return nil
}
return values
}
// Has returns whether a FormData object contains a certain key.
func (data FormData) Has(key string) bool {
values, ok := data[key]
return ok && len(values) > 0
}
// Keys returns all keys contained in the current FormData.
func (data FormData) Keys() []string {
result := make([]string, 0, len(data))
for k := range data {
result = append(result, k)
}
return result
}
// Keys returns all values contained in the current FormData.
func (data FormData) Values() []any {
result := make([]any, 0, len(data))
for _, values := range data {
for _, v := range values {
result = append(result, v)
}
}
return result
}
// Entries returns a [key, value] slice pair for each FormData entry.
func (data FormData) Entries() [][]any {
result := make([][]any, 0, len(data))
for k, values := range data {
for _, v := range values {
result = append(result, []any{k, v})
}
}
return result
}
// toMultipart converts the current FormData entries into multipart encoded data.
func (data FormData) toMultipart() (*bytes.Buffer, *multipart.Writer, error) {
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
defer mp.Close()
for k, values := range data {
for _, rawValue := range values {
switch v := rawValue.(type) {
case *filesystem.File:
err := func() error {
mpw, err := mp.CreateFormFile(k, v.OriginalName)
if err != nil {
return err
}
file, err := v.Reader.Open()
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(mpw, file); err != nil {
return err
}
return nil
}()
if err != nil {
return nil, nil, err
}
default:
if err := mp.WriteField(k, cast.ToString(v)); err != nil {
return nil, nil, err
}
}
}
}
return body, mp, nil
}
+225
View File
@@ -0,0 +1,225 @@
package jsvm
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestFormDataAppendAndSet(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
data.Append("b", 3)
data.Append("b", 4)
data.Set("b", 5) // should overwrite the previous 2 calls
data.Set("c", 6)
data.Set("c", 7)
if len(data["a"]) != 2 {
t.Fatalf("Expected 2 'a' values, got %v", data["a"])
}
if data["a"][0] != 1 || data["a"][1] != 2 {
t.Fatalf("Expected 1 and 2 'a' key values, got %v", data["a"])
}
if len(data["b"]) != 1 {
t.Fatalf("Expected 1 'b' values, got %v", data["b"])
}
if data["b"][0] != 5 {
t.Fatalf("Expected 5 as 'b' key value, got %v", data["b"])
}
if len(data["c"]) != 1 {
t.Fatalf("Expected 1 'c' values, got %v", data["c"])
}
if data["c"][0] != 7 {
t.Fatalf("Expected 7 as 'c' key value, got %v", data["c"])
}
}
func TestFormDataDelete(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
data.Append("b", 3)
data.Delete("missing") // should do nothing
data.Delete("a")
if len(data) != 1 {
t.Fatalf("Expected exactly 1 data remaining key, got %v", data)
}
if data["b"][0] != 3 {
t.Fatalf("Expected 3 as 'b' key value, got %v", data["b"])
}
}
func TestFormDataGet(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
if v := data.Get("missing"); v != nil {
t.Fatalf("Expected %v for key 'missing', got %v", nil, v)
}
if v := data.Get("a"); v != 1 {
t.Fatalf("Expected %v for key 'a', got %v", 1, v)
}
}
func TestFormDataGetAll(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
if v := data.GetAll("missing"); v != nil {
t.Fatalf("Expected %v for key 'a', got %v", nil, v)
}
values := data.GetAll("a")
if len(values) != 2 || values[0] != 1 || values[1] != 2 {
t.Fatalf("Expected 1 and 2 values, got %v", values)
}
}
func TestFormDataHas(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
if v := data.Has("missing"); v {
t.Fatalf("Expected key 'missing' to not exist: %v", v)
}
if v := data.Has("a"); !v {
t.Fatalf("Expected key 'a' to exist: %v", v)
}
}
func TestFormDataKeys(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 1)
data.Append("c", 1)
data.Append("a", 1)
keys := data.Keys()
expectedKeys := []string{"a", "b", "c"}
for _, expected := range expectedKeys {
if !list.ExistInSlice(expected, keys) {
t.Fatalf("Expected key %s to exists in %v", expected, keys)
}
}
}
func TestFormDataValues(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 2)
data.Append("c", 3)
data.Append("a", 4)
values := data.Values()
expectedKeys := []any{1, 2, 3, 4}
for _, expected := range expectedKeys {
if !list.ExistInSlice(expected, values) {
t.Fatalf("Expected key %s to exists in %v", expected, values)
}
}
}
func TestFormDataEntries(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 2)
data.Append("c", 3)
data.Append("a", 4)
entries := data.Entries()
rawEntries, err := json.Marshal(entries)
if err != nil {
t.Fatal(err)
}
if len(entries) != 4 {
t.Fatalf("Expected 4 entries")
}
expectedEntries := []string{`["a",1]`, `["a",4]`, `["b",2]`, `["c",3]`}
for _, expected := range expectedEntries {
if !bytes.Contains(rawEntries, []byte(expected)) {
t.Fatalf("Expected entry %s to exists in %s", expected, rawEntries)
}
}
}
func TestFormDataToMultipart(t *testing.T) {
t.Parallel()
f, err := filesystem.NewFileFromBytes([]byte("abc"), "test")
if err != nil {
t.Fatal(err)
}
data := FormData{}
data.Append("a", 1) // should be casted
data.Append("b", "test1")
data.Append("b", "test2")
data.Append("c", f)
body, mp, err := data.toMultipart()
if err != nil {
t.Fatal(err)
}
bodyStr := body.String()
// content type checks
contentType := mp.FormDataContentType()
expectedContentType := "multipart/form-data; boundary="
if !strings.Contains(contentType, expectedContentType) {
t.Fatalf("Expected to find content-type %s in %s", expectedContentType, contentType)
}
// body checks
expectedBodyParts := []string{
"Content-Disposition: form-data; name=\"a\"\r\n\r\n1",
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest1",
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest2",
"Content-Disposition: form-data; name=\"c\"; filename=\"test\"\r\nContent-Type: application/octet-stream\r\n\r\nabc",
}
for _, part := range expectedBodyParts {
if !strings.Contains(bodyStr, part) {
t.Fatalf("Expected to find %s in body\n%s", part, bodyStr)
}
}
}
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -96,7 +96,7 @@ declare function routerAdd(
* ` + "```" + `js
* routerUse((next) => {
* return (c) => {
* console.log(c.Path())
* console.log(c.path())
* return next(c)
* }
* })
@@ -641,6 +641,22 @@ declare namespace $filesystem {
let fileFromPath: filesystem.newFileFromPath
let fileFromBytes: filesystem.newFileFromBytes
let fileFromMultipart: filesystem.newFileFromMultipart
/**
* fileFromUrl creates a new File from the provided url by
* downloading the resource and creating a BytesReader.
*
* Example:
*
* ` + "```" + `js
* // with default max timeout of 120sec
* const file1 = $filesystem.fileFromUrl("https://...")
*
* // with custom timeout of 15sec
* const file2 = $filesystem.fileFromUrl("https://...", 15)
* ` + "```" + `
*/
export function fileFromUrl(url: string, secTimeout?: number): filesystem.File
}
// -------------------------------------------------------------------
@@ -978,6 +994,8 @@ declare namespace $apis {
let activityLogger: apis.activityLogger
let requestInfo: apis.requestInfo
let recordAuthResponse: apis.recordAuthResponse
let gzip: middleware.gzip
let bodyLimit: middleware.bodyLimit
let enrichRecord: apis.enrichRecord
let enrichRecords: apis.enrichRecords
}
@@ -986,6 +1004,12 @@ declare namespace $apis {
// httpClientBinds
// -------------------------------------------------------------------
// extra FormData overload to prevent TS warnings when used with non File/Blob value.
interface FormData {
append(key:string, value:any): void
set(key:string, value:any): void
}
/**
* ` + "`" + `$http` + "`" + ` defines common methods for working with HTTP requests.
*
@@ -1000,7 +1024,7 @@ declare namespace $http {
* ` + "```" + `js
* const res = $http.send({
* url: "https://example.com",
* data: {"title": "test"}
* body: JSON.stringify({"title": "test"})
* method: "post",
* })
*
@@ -1013,7 +1037,7 @@ declare namespace $http {
*/
function send(config: {
url: string,
body?: string,
body?: string|FormData,
method?: string, // default to "GET"
headers?: { [key:string]: string },
timeout?: number, // default to 120
@@ -1053,6 +1077,7 @@ func main() {
gen := tygoja.New(tygoja.Config{
Packages: map[string][]string{
"github.com/labstack/echo/v5/middleware": {"Gzip", "BodyLimit"},
"github.com/go-ozzo/ozzo-validation/v4": {"Error"},
"github.com/pocketbase/dbx": {"*"},
"github.com/pocketbase/pocketbase/tools/security": {"*"},
+19 -1
View File
@@ -41,6 +41,11 @@ const (
// Config defines the config options of the jsvm plugin.
type Config struct {
// OnInit is an optional function that will be called
// after a JS runtime is initialized, allowing you to
// attach custom Go variables and functions.
OnInit func(vm *goja.Runtime)
// HooksWatch enables auto app restarts when a JS app hook file changes.
//
// Note that currently the application cannot be automatically restarted on Windows
@@ -90,7 +95,12 @@ type Config struct {
//
// Example usage:
//
// jsvm.MustRegister(app, jsvm.Config{})
// jsvm.MustRegister(app, jsvm.Config{
// OnInit: func(vm *goja.Runtime) {
// // register custom bindings
// vm.Set("myCustomVar", 123)
// }
// })
func MustRegister(app core.App, config Config) {
if err := Register(app, config); err != nil {
panic(err)
@@ -173,6 +183,10 @@ func (p *plugin) registerMigrations() error {
m.AppMigrations.Register(up, down, file)
})
if p.config.OnInit != nil {
p.config.OnInit(vm)
}
_, err := vm.RunString(string(content))
if err != nil {
return fmt.Errorf("failed to run migration %s: %w", file, err)
@@ -253,6 +267,10 @@ func (p *plugin) registerHooks() error {
vm.Set("$app", p.app)
vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir)
if p.config.OnInit != nil {
p.config.OnInit(vm)
}
}
// initiliaze the executor vms
+31 -15
View File
@@ -532,19 +532,23 @@ func init() {
collection.DeleteRule = nil
options := map[string]any{}
json.Unmarshal([]byte(` + "`" + `{}` + "`" + `), &options)
if err := json.Unmarshal([]byte(` + "`" + `{}` + "`" + `), &options); err != nil {
return err
}
collection.SetOptions(options)
json.Unmarshal([]byte(` + "`" + `[
if err := json.Unmarshal([]byte(` + "`" + `[
"create index test1 on test456_update (f1_name)"
]` + "`" + `), &collection.Indexes)
]` + "`" + `), &collection.Indexes); err != nil {
return err
}
// remove
collection.Schema.RemoveField("f3_id")
// add
new_f4_name := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
if err := json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f4_id",
"name": "f4_name",
@@ -557,12 +561,14 @@ func init() {
"max": null,
"pattern": ` + "\"` + \"`\" + `test backtick` + \"`\" + `123\"" + `
}
}` + "`" + `), new_f4_name)
}` + "`" + `), new_f4_name); err != nil {
return err
}
collection.Schema.AddField(new_f4_name)
// update
edit_f2_name_new := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
if err := json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f2_id",
"name": "f2_name_new",
@@ -575,7 +581,9 @@ func init() {
"max": null,
"noDecimal": false
}
}` + "`" + `), edit_f2_name_new)
}` + "`" + `), edit_f2_name_new); err != nil {
return err
}
collection.Schema.AddField(edit_f2_name_new)
return dao.SaveCollection(collection)
@@ -600,7 +608,7 @@ func init() {
collection.DeleteRule = types.Pointer("id = \"3\"")
options := map[string]any{}
json.Unmarshal([]byte(` + "`" + `{
if err := json.Unmarshal([]byte(` + "`" + `{
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
@@ -610,16 +618,20 @@ func init() {
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}` + "`" + `), &options)
}` + "`" + `), &options); err != nil {
return err
}
collection.SetOptions(options)
json.Unmarshal([]byte(` + "`" + `[
if err := json.Unmarshal([]byte(` + "`" + `[
"create index test1 on test456 (f1_name)"
]` + "`" + `), &collection.Indexes)
]` + "`" + `), &collection.Indexes); err != nil {
return err
}
// add
del_f3_name := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
if err := json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f3_id",
"name": "f3_name",
@@ -628,7 +640,9 @@ func init() {
"presentable": false,
"unique": false,
"options": {}
}` + "`" + `), del_f3_name)
}` + "`" + `), del_f3_name); err != nil {
return err
}
collection.Schema.AddField(del_f3_name)
// remove
@@ -636,7 +650,7 @@ func init() {
// update
edit_f2_name_new := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
if err := json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f2_id",
"name": "f2_name",
@@ -649,7 +663,9 @@ func init() {
"max": null,
"noDecimal": false
}
}` + "`" + `), edit_f2_name_new)
}` + "`" + `), edit_f2_name_new); err != nil {
return err
}
collection.Schema.AddField(edit_f2_name_new)
return dao.SaveCollection(collection)
+12 -8
View File
@@ -598,11 +598,11 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection)
}
if !bytes.Equal(rawNewOptions, rawOldOptions) {
upParts = append(upParts, "options := map[string]any{}")
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawNewOptions))))
upParts = append(upParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawNewOptions)))))
upParts = append(upParts, fmt.Sprintf("%s.SetOptions(options)\n", varName))
// ---
downParts = append(downParts, "options := map[string]any{}")
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawOldOptions))))
downParts = append(downParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawOldOptions)))))
downParts = append(downParts, fmt.Sprintf("%s.SetOptions(options)\n", varName))
}
@@ -616,9 +616,9 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection)
return "", err
}
if !bytes.Equal(rawNewIndexes, rawOldIndexes) {
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawNewIndexes)), varName))
upParts = append(upParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)", escapeBacktick(string(rawNewIndexes)), varName))+"\n")
// ---
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawOldIndexes)), varName))
downParts = append(downParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)", escapeBacktick(string(rawOldIndexes)), varName))+"\n")
}
// Schema
@@ -641,7 +641,7 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection)
downParts = append(downParts, "// add")
downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar))
downParts = append(downParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar)))
downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
}
@@ -660,7 +660,7 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection)
upParts = append(upParts, "// add")
upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar))
upParts = append(upParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar)))
upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
downParts = append(downParts, "// remove")
@@ -692,12 +692,12 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection)
upParts = append(upParts, "// update")
upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar))
upParts = append(upParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar)))
upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
downParts = append(downParts, "// update")
downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar))
downParts = append(downParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar)))
downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
}
// ---------------------------------------------------------------
@@ -791,3 +791,7 @@ func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error)
func escapeBacktick(v string) string {
return strings.ReplaceAll(v, "`", "` + \"`\" + `")
}
func goErrIf(v string) string {
return "if err := " + v + "; err != nil {\n\t\t\treturn err\n\t\t}"
}
+175 -75
View File
@@ -3,18 +3,23 @@ package resolvers
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
)
// maxNestedRels defines the max allowed nested relations depth.
const maxNestedRels = 6
// parseAndRun starts a new one-off RecordFieldResolver.Resolve execution.
func parseAndRun(fieldName string, resolver *RecordFieldResolver) (*search.ResolverResult, error) {
r := &runner{
@@ -90,9 +95,9 @@ func (r *runner) run() (*search.ResolverResult, error) {
return r.processRequestInfoRelationField(dataField)
}
// check for select:each field
if modifier == eachModifier && dataField.Type == schema.FieldTypeSelect && len(r.activeProps) == 3 {
return r.processRequestInfoSelectEachModifier(dataField)
// check for data arrayble fields ":each" modifier
if modifier == eachModifier && list.ExistInSlice(dataField.Type, schema.ArraybleFieldTypes()) && len(r.activeProps) == 3 {
return r.processRequestInfoEachModifier(dataField)
}
// check for data arrayble fields ":length" modifier
@@ -235,22 +240,22 @@ func (r *runner) processRequestInfoLengthModifier(dataField *schema.SchemaField)
return result, nil
}
func (r *runner) processRequestInfoSelectEachModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
options, ok := dataField.Options.(*schema.SelectOptions)
func (r *runner) processRequestInfoEachModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
options, ok := dataField.Options.(schema.MultiValuer)
if !ok {
return nil, fmt.Errorf("failed to initialize field %q options", dataField.Name)
return nil, fmt.Errorf("field %q options are not initialized or doesn't support multivaluer operations", dataField.Name)
}
dataItems := list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name])
rawJson, err := json.Marshal(dataItems)
if err != nil {
return nil, fmt.Errorf("cannot marshalize the data select item for field %q", r.activeProps[2])
return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2])
}
placeholder := "dataSelect" + security.PseudorandomString(4)
placeholder := "dataEach" + security.PseudorandomString(4)
cleanFieldName := inflector.Columnify(dataField.Name)
jeTable := fmt.Sprintf("json_each({:%s})", placeholder)
jeAlias := "__dataSelect_" + cleanFieldName + "_je"
jeAlias := "__dataEach_" + cleanFieldName + "_je"
r.resolver.registerJoin(jeTable, jeAlias, nil)
result := &search.ResolverResult{
@@ -258,7 +263,7 @@ func (r *runner) processRequestInfoSelectEachModifier(dataField *schema.SchemaFi
Params: dbx.Params{placeholder: rawJson},
}
if options.MaxSelect != 1 {
if options.IsMultiple() {
r.withMultiMatch = true
}
@@ -300,30 +305,33 @@ func (r *runner) processRequestInfoRelationField(dataField *schema.SchemaField)
}
r.activeCollectionName = dataRelCollection.Name
r.activeTableAlias = inflector.Columnify("__data_" + dataRelCollection.Name)
r.activeTableAlias = inflector.Columnify("__data_" + dataRelCollection.Name + "_" + dataField.Name)
// join the data rel collection to the main collection
r.resolver.registerJoin(
inflector.Columnify(r.activeCollectionName),
r.activeCollectionName,
r.activeTableAlias,
dbx.In(
fmt.Sprintf("[[%s.id]]", inflector.Columnify(r.activeTableAlias)),
fmt.Sprintf("[[%s.id]]", r.activeTableAlias),
list.ToInterfaceSlice(dataRelIds)...,
),
)
if options.MaxSelect == nil || *options.MaxSelect != 1 {
if options.IsMultiple() {
r.withMultiMatch = true
}
// join the data rel collection to the multi-match subquery
r.multiMatchActiveTableAlias = inflector.Columnify("__data_mm_" + dataRelCollection.Name)
r.multiMatchActiveTableAlias = inflector.Columnify("__data_mm_" + dataRelCollection.Name + "_" + dataField.Name)
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: inflector.Columnify(r.activeCollectionName),
tableName: r.activeCollectionName,
tableAlias: r.multiMatchActiveTableAlias,
on: dbx.In(r.multiMatchActiveTableAlias+".id", list.ToInterfaceSlice(dataRelIds)...),
on: dbx.In(
fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias),
list.ToInterfaceSlice(dataRelIds)...,
),
},
)
@@ -334,6 +342,8 @@ func (r *runner) processRequestInfoRelationField(dataField *schema.SchemaField)
return r.processActiveProps()
}
var viaRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
func (r *runner) processActiveProps() (*search.ResolverResult, error) {
totalProps := len(r.activeProps)
@@ -387,42 +397,41 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
cleanFieldName := inflector.Columnify(field.Name)
// arrayble fields ":length" modifier
// arrayable fields with ":length" modifier
// -------------------------------------------------------
if modifier == lengthModifier && list.ExistInSlice(field.Type, schema.ArraybleFieldTypes()) {
jePair := r.activeTableAlias + "." + cleanFieldName
result := &search.ResolverResult{
Identifier: jsonArrayLength(jePair),
Identifier: dbutils.JsonArrayLength(jePair),
}
if r.withMultiMatch {
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
r.multiMatch.valueIdentifier = jsonArrayLength(jePair2)
r.multiMatch.valueIdentifier = dbutils.JsonArrayLength(jePair2)
result.MultiMatchSubQuery = r.multiMatch
}
return result, nil
}
// select field with ":each" modifier
// arrayable fields with ":each" modifier
// -------------------------------------------------------
if field.Type == schema.FieldTypeSelect && modifier == eachModifier {
if modifier == eachModifier && list.ExistInSlice(field.Type, schema.ArraybleFieldTypes()) {
jePair := r.activeTableAlias + "." + cleanFieldName
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
r.resolver.registerJoin(jsonEach(jePair), jeAlias, nil)
r.resolver.registerJoin(dbutils.JsonEach(jePair), jeAlias, nil)
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
}
field.InitOptions()
options, ok := field.Options.(*schema.SelectOptions)
options, ok := field.Options.(schema.MultiValuer)
if !ok {
return nil, fmt.Errorf("failed to initialize field %q options", prop)
return nil, fmt.Errorf("field %q options are not initialized or doesn't multivaluer arrayable operations", prop)
}
if options.MaxSelect != 1 {
if options.IsMultiple() {
r.withMultiMatch = true
}
@@ -431,7 +440,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: jsonEach(jePair2),
tableName: dbutils.JsonEach(jePair2),
tableAlias: jeAlias2,
})
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
@@ -458,9 +467,9 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// (https://github.com/pocketbase/pocketbase/issues/4068)
if field.Type == schema.FieldTypeJson {
result.NoCoalesce = true
result.Identifier = jsonExtract(r.activeTableAlias+"."+cleanFieldName, "")
result.Identifier = dbutils.JsonExtract(r.activeTableAlias+"."+cleanFieldName, "")
if r.withMultiMatch {
r.multiMatch.valueIdentifier = jsonExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
r.multiMatch.valueIdentifier = dbutils.JsonExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
}
}
@@ -468,23 +477,19 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
}
field := collection.Schema.GetFieldByName(prop)
if field == nil {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("unknown field %q", prop)
}
// check if it is a json field
if field.Type == schema.FieldTypeJson {
// json field -> treat the rest of the props as json path
if field != nil && field.Type == schema.FieldTypeJson {
var jsonPath strings.Builder
for _, p := range r.activeProps[i+1:] {
for j, p := range r.activeProps[i+1:] {
if _, err := strconv.Atoi(p); err == nil {
jsonPath.WriteString("[")
jsonPath.WriteString(inflector.Columnify(p))
jsonPath.WriteString("]")
} else {
jsonPath.WriteString(".")
if j > 0 {
jsonPath.WriteString(".")
}
jsonPath.WriteString(inflector.Columnify(p))
}
}
@@ -492,18 +497,141 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
result := &search.ResolverResult{
NoCoalesce: true,
Identifier: jsonExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
Identifier: dbutils.JsonExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = jsonExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
r.multiMatch.valueIdentifier = dbutils.JsonExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
result.MultiMatchSubQuery = r.multiMatch
}
return result, nil
}
// check if it is a relation field
if i >= maxNestedRels {
return nil, fmt.Errorf("max nested relations reached for field %q", prop)
}
// check for back relation (eg. yourCollection_via_yourRelField)
// -----------------------------------------------------------
if field == nil {
parts := viaRegex.FindStringSubmatch(prop)
if len(parts) != 3 {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("failed to resolve field %q", prop)
}
backCollection, err := r.resolver.loadCollection(parts[1])
if err != nil {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("failed to load back relation field %q collection", prop)
}
backField := backCollection.Schema.GetFieldByName(parts[2])
if backField == nil {
if r.nullifyMisingField {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
return nil, fmt.Errorf("missing back relation field %q", parts[2])
}
if backField.Type != schema.FieldTypeRelation {
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
}
backField.InitOptions()
backFieldOptions, ok := backField.Options.(*schema.RelationOptions)
if !ok {
return nil, fmt.Errorf("failed to initialize back relation field %q options", backField.Name)
}
if backFieldOptions.CollectionId != collection.Id {
return nil, fmt.Errorf("invalid back relation field %q collection reference", backField.Name)
}
// join the back relation to the main query
// ---
cleanProp := inflector.Columnify(prop)
cleanBackFieldName := inflector.Columnify(backField.Name)
newTableAlias := r.activeTableAlias + "_" + cleanProp
newCollectionName := inflector.Columnify(backCollection.Name)
isBackRelMultiple := backFieldOptions.IsMultiple()
if !isBackRelMultiple {
// additionally check if the rel field has a single column unique index
isBackRelMultiple = !dbutils.HasSingleColumnUniqueIndex(backField.Name, backCollection.Indexes)
}
if !isBackRelMultiple {
r.resolver.registerJoin(
newCollectionName,
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias, cleanBackFieldName, r.activeTableAlias)),
)
} else {
jeAlias := r.activeTableAlias + "_" + cleanProp + "_je"
r.resolver.registerJoin(
newCollectionName,
newTableAlias,
dbx.NewExp(fmt.Sprintf(
"[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
r.activeTableAlias,
jeAlias,
dbutils.JsonEach(newTableAlias+"."+cleanBackFieldName),
jeAlias,
)),
)
}
r.activeCollectionName = newCollectionName
r.activeTableAlias = newTableAlias
// ---
// join the back relation to the multi-match subquery
// ---
if isBackRelMultiple {
r.withMultiMatch = true // enable multimatch if not already
}
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp
if !isBackRelMultiple {
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: newCollectionName,
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
},
)
} else {
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + "_je"
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: newCollectionName,
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf(
"[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
r.multiMatchActiveTableAlias,
jeAlias2,
dbutils.JsonEach(newTableAlias2+"."+cleanBackFieldName),
jeAlias2,
)),
},
)
}
r.multiMatchActiveTableAlias = newTableAlias2
// ---
continue
}
// -----------------------------------------------------------
// check for direct relation
if field.Type != schema.FieldTypeRelation {
return nil, fmt.Errorf("field %q is not a valid relation", prop)
}
@@ -518,7 +646,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
relCollection, relErr := r.resolver.loadCollection(options.CollectionId)
if relErr != nil {
return nil, fmt.Errorf("failed to find field %q collection", prop)
return nil, fmt.Errorf("failed to load field %q collection", prop)
}
cleanFieldName := inflector.Columnify(field.Name)
@@ -534,7 +662,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
)
} else {
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
r.resolver.registerJoin(jsonEach(prefixedFieldName), jeAlias, nil)
r.resolver.registerJoin(dbutils.JsonEach(prefixedFieldName), jeAlias, nil)
r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
@@ -549,7 +677,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// join the relation to the multi-match subquery
// ---
if options.IsMultiple() {
r.withMultiMatch = true
r.withMultiMatch = true // enable multimatch if not already
}
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName
@@ -569,7 +697,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: jsonEach(prefixedFieldName2),
tableName: dbutils.JsonEach(prefixedFieldName2),
tableAlias: jeAlias2,
},
&join{
@@ -587,34 +715,6 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("failed to resolve field %q", r.fieldName)
}
func jsonArrayLength(tableColumnPair string) string {
return fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
`json_array_length(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE (CASE WHEN [[%s]] = '' OR [[%s]] IS NULL THEN json_array() ELSE json_array([[%s]]) END) END)`,
tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair,
)
}
func jsonEach(tableColumnPair string) string {
return fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END)`,
tableColumnPair, tableColumnPair, tableColumnPair,
)
}
func jsonExtract(tableColumnPair string, path string) string {
return fmt.Sprintf(
// note: the extra object wrapping is needed to workaround the cases where a json_extract is used with non-json columns.
"(CASE WHEN json_valid([[%s]]) THEN JSON_EXTRACT([[%s]], '$%s') ELSE JSON_EXTRACT(json_object('pb', [[%s]]), '$.pb%s') END)",
tableColumnPair,
tableColumnPair,
path,
tableColumnPair,
path,
)
}
func resolvableSystemFieldNames(collection *models.Collection) []string {
result := schema.BaseModelFieldNames()
+9 -4
View File
@@ -89,6 +89,7 @@ func NewRecordFieldResolver(
loadedCollections: []*models.Collection{baseCollection},
allowedFields: []string{
`^\w+[\w\.\:]*$`,
`^\@request\.context$`,
`^\@request\.method$`,
`^\@request\.auth\.[\w\.\:]*\w+$`,
`^\@request\.data\.[\w\.\:]*\w+$`,
@@ -100,15 +101,17 @@ func NewRecordFieldResolver(
r.staticRequestInfo = map[string]any{}
if r.requestInfo != nil {
r.staticRequestInfo["context"] = r.requestInfo.Context
r.staticRequestInfo["method"] = r.requestInfo.Method
r.staticRequestInfo["query"] = r.requestInfo.Query
r.staticRequestInfo["headers"] = r.requestInfo.Headers
r.staticRequestInfo["data"] = r.requestInfo.Data
r.staticRequestInfo["auth"] = nil
if r.requestInfo.AuthRecord != nil {
r.requestInfo.AuthRecord.IgnoreEmailVisibility(true)
r.staticRequestInfo["auth"] = r.requestInfo.AuthRecord.PublicExport()
r.requestInfo.AuthRecord.IgnoreEmailVisibility(false)
authData := r.requestInfo.AuthRecord.PublicExport()
// always add the record email no matter of the emailVisibility field
authData[schema.FieldNameEmail] = r.requestInfo.AuthRecord.Email()
r.staticRequestInfo["auth"] = authData
}
}
@@ -141,7 +144,9 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
// id
// someSelect.each
// project.screen.status
// @request.status
// screen.project_via_prototype.name
// @request.context
// @request.method
// @request.query.filter
// @request.headers.x_token
// @request.auth.someRelation.name
File diff suppressed because one or more lines are too long
+50 -4
View File
@@ -9,8 +9,12 @@ import (
"runtime"
"sync"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/migrate"
)
// TestApp is a wrapper app instance used for testing.
@@ -36,20 +40,27 @@ type TestApp struct {
//
// After this call, the app instance shouldn't be used anymore.
func (t *TestApp) Cleanup() {
t.ResetEventCalls()
t.ResetBootstrapState()
t.OnTerminate().Trigger(&core.TerminateEvent{App: t}, func(e *core.TerminateEvent) error {
t.TestMailer.Reset()
t.ResetEventCalls()
t.ResetBootstrapState()
return nil
})
if t.DataDir() != "" {
os.RemoveAll(t.DataDir())
}
}
// NewMailClient initializes test app mail client.
// NewMailClient initializes (if not already) a test app mail client.
func (t *TestApp) NewMailClient() mailer.Mailer {
t.mux.Lock()
defer t.mux.Unlock()
t.TestMailer.Reset()
if t.TestMailer == nil {
t.TestMailer = &TestMailer{}
}
return t.TestMailer
}
@@ -112,6 +123,11 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return nil, err
}
// apply any missing migrations
if err := runMigrations(app); err != nil {
return nil, err
}
// force disable request logs because the logs db call execute in a separate
// go routine and it is possible to panic due to earlier api test completion.
app.Settings().Logs.MaxDays = 0
@@ -542,3 +558,33 @@ func copyFile(src string, dest string) error {
return nil
}
// @todo replace with app.RunMigrations on merge with the refactoring.
func runMigrations(app core.App) error {
connections := []struct {
db *dbx.DB
migrationsList migrate.MigrationsList
}{
{
db: app.DB(),
migrationsList: migrations.AppMigrations,
},
{
db: app.LogsDB(),
migrationsList: logs.LogsMigrations,
},
}
for _, c := range connections {
runner, err := migrate.NewRunner(c.db, c.migrationsList)
if err != nil {
return err
}
if _, err := runner.Up(); err != nil {
return err
}
}
return nil
}
Binary file not shown.
+21 -6
View File
@@ -1,6 +1,8 @@
package tests
import (
"sync"
"github.com/pocketbase/pocketbase/tools/mailer"
)
@@ -8,20 +10,33 @@ var _ mailer.Mailer = (*TestMailer)(nil)
// TestMailer is a mock `mailer.Mailer` implementation.
type TestMailer struct {
mux sync.Mutex
TotalSend int
LastMessage mailer.Message
// @todo consider deprecating the above 2 fields?
SentMessages []mailer.Message
}
// Reset clears any previously test collected data.
func (m *TestMailer) Reset() {
m.TotalSend = 0
m.LastMessage = mailer.Message{}
func (tm *TestMailer) Reset() {
tm.mux.Lock()
defer tm.mux.Unlock()
tm.TotalSend = 0
tm.LastMessage = mailer.Message{}
tm.SentMessages = nil
}
// Send implements `mailer.Mailer` interface.
func (c *TestMailer) Send(m *mailer.Message) error {
c.TotalSend++
c.LastMessage = *m
func (tm *TestMailer) Send(m *mailer.Message) error {
tm.mux.Lock()
defer tm.mux.Unlock()
tm.TotalSend++
tm.LastMessage = *m
tm.SentMessages = append(tm.SentMessages, tm.LastMessage)
return nil
}
+4 -4
View File
@@ -12,7 +12,7 @@ import (
// NewRecordAuthToken generates and returns a new auth record authentication token.
func NewRecordAuthToken(app core.App, record *models.Record) (string, error) {
if !record.Collection().IsAuth() {
return "", errors.New("The record is not from an auth collection.")
return "", errors.New("the record is not from an auth collection")
}
return security.NewJWT(
@@ -29,7 +29,7 @@ func NewRecordAuthToken(app core.App, record *models.Record) (string, error) {
// NewRecordVerifyToken generates and returns a new record verification token.
func NewRecordVerifyToken(app core.App, record *models.Record) (string, error) {
if !record.Collection().IsAuth() {
return "", errors.New("The record is not from an auth collection.")
return "", errors.New("the record is not from an auth collection")
}
return security.NewJWT(
@@ -47,7 +47,7 @@ func NewRecordVerifyToken(app core.App, record *models.Record) (string, error) {
// NewRecordResetPasswordToken generates and returns a new auth record password reset request token.
func NewRecordResetPasswordToken(app core.App, record *models.Record) (string, error) {
if !record.Collection().IsAuth() {
return "", errors.New("The record is not from an auth collection.")
return "", errors.New("the record is not from an auth collection")
}
return security.NewJWT(
@@ -80,7 +80,7 @@ func NewRecordChangeEmailToken(app core.App, record *models.Record, newEmail str
// NewRecordFileToken generates and returns a new record private file access token.
func NewRecordFileToken(app core.App, record *models.Record) (string, error) {
if !record.Collection().IsAuth() {
return "", errors.New("The record is not from an auth collection.")
return "", errors.New("the record is not from an auth collection")
}
return security.NewJWT(
+6 -1
View File
@@ -48,7 +48,7 @@ func TestCreateSuccess(t *testing.T) {
t.Fatalf("Expected zip with name %q, got %q", zipName, name)
}
expectedSize := int64(405)
expectedSize := int64(544)
if size := info.Size(); size != expectedSize {
t.Fatalf("Expected zip with size %d, got %d", expectedSize, size)
}
@@ -116,5 +116,10 @@ func createTestDir(t *testing.T) string {
f.Close()
}
// symbolic link
if err := os.Symlink(filepath.Join(dir, "test"), filepath.Join(dir, "test_symlink")); err != nil {
t.Fatal(err)
}
return dir
}
+7 -2
View File
@@ -9,7 +9,11 @@ import (
"strings"
)
// Extract extracts the zip archive at src to dest.
// Extract extracts the zip archive at "src" to "dest".
//
// Note that only dirs and regular files will be extracted.
// Symbolic links, named pipes, sockets, or any other irregular files
// are skipped because they come with too many edge cases and ambiguities.
func Extract(src, dest string) error {
zr, err := zip.OpenReader(src)
if err != nil {
@@ -46,11 +50,12 @@ func extractFile(zipFile *zip.File, basePath string) error {
}
defer r.Close()
// allow only dirs or regular files
if zipFile.FileInfo().IsDir() {
if err := os.MkdirAll(path, os.ModePerm); err != nil {
return err
}
} else {
} else if zipFile.FileInfo().Mode().IsRegular() {
// ensure that the file path directories are created
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return err
+3 -2
View File
@@ -37,7 +37,7 @@ func TestExtractSuccess(t *testing.T) {
defer os.RemoveAll(extractedPath)
// zip testDir content (with exclude)
if err := archive.Create(testDir, zipPath, "a/b/c", "test", "sub2"); err != nil {
if err := archive.Create(testDir, zipPath, "a/b/c", "test2", "sub2"); err != nil {
t.Fatalf("Failed to create archive: %v", err)
}
@@ -64,8 +64,9 @@ func TestExtractSuccess(t *testing.T) {
t.Fatalf("Failed to read the extracted dir: %v", walkErr)
}
// (note: symbolic links and other regular files should be missing)
expectedFiles := []string{
filepath.Join(extractedPath, "test2"),
filepath.Join(extractedPath, "test"),
filepath.Join(extractedPath, "a/test"),
filepath.Join(extractedPath, "a/b/sub1"),
}
+3 -1
View File
@@ -39,7 +39,7 @@ func NewAppleProvider() *Apple {
ctx: context.Background(),
displayName: "Apple",
pkce: true,
scopes: nil, // custom scopes are currently not supported since they require a POST redirect
scopes: []string{"name", "email"},
authUrl: "https://appleid.apple.com/auth/authorize",
tokenUrl: "https://appleid.apple.com/auth/token",
},
@@ -63,6 +63,7 @@ func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
extracted := struct {
Id string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"` // could be string or bool
}{}
@@ -72,6 +73,7 @@ func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
+5 -1
View File
@@ -24,7 +24,7 @@ type AuthUser struct {
// Provider defines a common interface for an OAuth2 client.
type Provider interface {
// Scopes returns the context associated with the provider (if any).
// Context returns the context associated with the provider (if any).
Context() context.Context
// SetContext assigns the specified context to the current provider.
@@ -154,6 +154,10 @@ func NewProviderByName(name string) (Provider, error) {
return NewPatreonProvider(), nil
case NameMailcow:
return NewMailcowProvider(), nil
case NameBitbucket:
return NewBitbucketProvider(), nil
case NamePlanningcenter:
return NewPlanningcenterProvider(), nil
default:
return nil, errors.New("Missing provider " + name)
}
+18
View File
@@ -225,4 +225,22 @@ func TestNewProviderByName(t *testing.T) {
if _, ok := p.(*auth.Mailcow); !ok {
t.Error("Expected to be instance of *auth.Mailcow")
}
// bitbucket
p, err = auth.NewProviderByName(auth.NameBitbucket)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Bitbucket); !ok {
t.Error("Expected to be instance of *auth.Bitbucket")
}
// planningcenter
p, err = auth.NewProviderByName(auth.NamePlanningcenter)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Planningcenter); !ok {
t.Error("Expected to be instance of *auth.Planningcenter")
}
}
+132
View File
@@ -0,0 +1,132 @@
package auth
import (
"context"
"encoding/json"
"errors"
"io"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
var _ Provider = (*Bitbucket)(nil)
// NameBitbucket is the unique name of the Bitbucket provider.
const NameBitbucket = "bitbucket"
// Bitbucket is an auth provider for Bitbucket.
type Bitbucket struct {
*baseProvider
}
// NewBitbucketProvider creates a new Bitbucket provider instance with some defaults.
func NewBitbucketProvider() *Bitbucket {
return &Bitbucket{&baseProvider{
ctx: context.Background(),
displayName: "Bitbucket",
pkce: false,
scopes: []string{"account"},
authUrl: "https://bitbucket.org/site/oauth2/authorize",
tokenUrl: "https://bitbucket.org/site/oauth2/access_token",
userApiUrl: "https://api.bitbucket.org/2.0/user",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Bitbucket's user API.
//
// API reference: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get
func (p *Bitbucket) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
UUID string `json:"uuid"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
AccountStatus string `json:"account_status"`
Links struct {
Avatar struct {
Href string `json:"href"`
} `json:"avatar"`
} `json:"links"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if extracted.AccountStatus != "active" {
return nil, errors.New("the Bitbucket user is not active")
}
email, err := p.fetchPrimaryEmail(token)
if err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.UUID,
Name: extracted.DisplayName,
Username: extracted.Username,
Email: email,
AvatarUrl: extracted.Links.Avatar.Href,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// fetchPrimaryEmail sends an API request to retrieve the first
// verified primary email.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-emails-get
func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
response, err := p.Client(token).Get(p.userApiUrl + "/emails")
if err != nil {
return "", err
}
defer response.Body.Close()
// ignore common http errors caused by insufficient scope permissions
// (the email field is optional, aka. return the auth user without it)
if response.StatusCode >= 400 {
return "", nil
}
data, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
expected := struct {
Values []struct {
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
} `json:"values"`
}{}
if err := json.Unmarshal(data, &expected); err != nil {
return "", err
}
for _, v := range expected.Values {
if v.IsPrimary {
return v.Email, nil
}
}
return "", nil
}
+81
View File
@@ -0,0 +1,81 @@
package auth
import (
"context"
"encoding/json"
"errors"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
var _ Provider = (*Planningcenter)(nil)
// NamePlanningcenter is the unique name of the Planningcenter provider.
const NamePlanningcenter string = "planningcenter"
// Planningcenter allows authentication via Planningcenter OAuth2.
type Planningcenter struct {
*baseProvider
}
// NewPlanningcenterProvider creates a new Planningcenter provider instance with some defaults.
func NewPlanningcenterProvider() *Planningcenter {
return &Planningcenter{&baseProvider{
ctx: context.Background(),
displayName: "Planning Center",
pkce: true,
scopes: []string{"people"},
authUrl: "https://api.planningcenteronline.com/oauth/authorize",
tokenUrl: "https://api.planningcenteronline.com/oauth/token",
userApiUrl: "https://api.planningcenteronline.com/people/v2/me",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Planningcenter's user api.
//
// API reference: https://developer.planning.center/docs/#/overview/authentication
func (p *Planningcenter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Id string `json:"id"`
Attributes struct {
Status string `json:"status"`
Name string `json:"name"`
AvatarUrl string `json:"avatar"`
// don't map the email because users can have multiple assigned
// and it's not clear if they are verified
}
}
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if extracted.Data.Attributes.Status != "active" {
return nil, errors.New("the user is not active")
}
user := &AuthUser{
Id: extracted.Data.Id,
Name: extracted.Data.Attributes.Name,
AvatarUrl: extracted.Data.Attributes.AvatarUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
+43 -16
View File
@@ -22,12 +22,14 @@ type job struct {
// Cron is a crontab-like struct for tasks/jobs scheduling.
type Cron struct {
sync.RWMutex
timezone *time.Location
ticker *time.Ticker
startTimer *time.Timer
jobs map[string]*job
interval time.Duration
tickerDone chan bool
interval time.Duration
timezone *time.Location
ticker *time.Ticker
jobs map[string]*job
sync.RWMutex
}
// New create a new Cron struct with default tick interval of 1 minute
@@ -37,9 +39,10 @@ type Cron struct {
// You can change the default timezone with Cron.SetTimezone().
func New() *Cron {
return &Cron{
interval: 1 * time.Minute,
timezone: time.UTC,
jobs: map[string]*job{},
interval: 1 * time.Minute,
timezone: time.UTC,
jobs: map[string]*job{},
tickerDone: make(chan bool),
}
}
@@ -132,10 +135,16 @@ func (c *Cron) Stop() {
c.Lock()
defer c.Unlock()
if c.startTimer != nil {
c.startTimer.Stop()
c.startTimer = nil
}
if c.ticker == nil {
return // already stopped
}
c.tickerDone <- true
c.ticker.Stop()
c.ticker = nil
}
@@ -146,15 +155,33 @@ func (c *Cron) Stop() {
func (c *Cron) Start() {
c.Stop()
c.Lock()
c.ticker = time.NewTicker(c.interval)
c.Unlock()
// delay the ticker to start at 00 of 1 c.interval duration
now := time.Now()
next := now.Add(c.interval).Truncate(c.interval)
delay := next.Sub(now)
go func() {
for t := range c.ticker.C {
c.runDue(t)
}
}()
c.Lock()
c.startTimer = time.AfterFunc(delay, func() {
c.Lock()
c.ticker = time.NewTicker(c.interval)
c.Unlock()
// run immediately at 00
c.runDue(time.Now())
// run after each tick
go func() {
for {
select {
case <-c.tickerDone:
return
case t := <-c.ticker.C:
c.runDue(t)
}
}
}()
})
c.Unlock()
}
// HasStarted checks whether the current Cron ticker has been started.
+11 -9
View File
@@ -211,13 +211,13 @@ func TestCronTotal(t *testing.T) {
func TestCronStartStop(t *testing.T) {
t.Parallel()
c := New()
c.SetInterval(1 * time.Second)
test1 := 0
test2 := 0
c := New()
c.SetInterval(500 * time.Millisecond)
c.Add("test1", "* * * * *", func() {
test1++
})
@@ -226,13 +226,13 @@ func TestCronStartStop(t *testing.T) {
test2++
})
expectedCalls := 3
expectedCalls := 2
// call twice Start to check if the previous ticker will be reseted
c.Start()
c.Start()
time.Sleep(3250 * time.Millisecond)
time.Sleep(1 * time.Second)
// call twice Stop to ensure that the second stop is no-op
c.Stop()
@@ -245,12 +245,14 @@ func TestCronStartStop(t *testing.T) {
t.Fatalf("Expected %d test2, got %d", expectedCalls, test2)
}
// resume for ~5 seconds
// resume for 2 seconds
c.Start()
time.Sleep(5250 * time.Millisecond)
time.Sleep(2 * time.Second)
c.Stop()
expectedCalls += 5
expectedCalls += 4
if test1 != expectedCalls {
t.Fatalf("Expected %d test1, got %d", expectedCalls, test1)
+13
View File
@@ -192,3 +192,16 @@ func ParseIndex(createIndexExpr string) Index {
return result
}
// HasColumnUniqueIndex loosely checks whether the specified column has
// a single column unique index (WHERE statements are ignored).
func HasSingleColumnUniqueIndex(column string, indexes []string) bool {
for _, idx := range indexes {
parsed := ParseIndex(idx)
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, column) {
return true
}
}
return false
}
+108 -21
View File
@@ -3,6 +3,7 @@ package dbutils_test
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/pocketbase/tools/dbutils"
@@ -68,21 +69,23 @@ func TestParseIndex(t *testing.T) {
}
for i, s := range scenarios {
result := dbutils.ParseIndex(s.index)
t.Run(fmt.Sprintf("scenario_%d", i), func(t *testing.T) {
result := dbutils.ParseIndex(s.index)
resultRaw, err := json.Marshal(result)
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
resultRaw, err := json.Marshal(result)
if err != nil {
t.Fatalf("Faild to marshalize parse result: %v", err)
}
expectedRaw, err := json.Marshal(s.expected)
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
expectedRaw, err := json.Marshal(s.expected)
if err != nil {
t.Fatalf("Failed to marshalize expected index: %v", err)
}
if !bytes.Equal(resultRaw, expectedRaw) {
t.Errorf("[%d] Expected \n%s \ngot \n%s", i, expectedRaw, resultRaw)
}
if !bytes.Equal(resultRaw, expectedRaw) {
t.Errorf("Expected \n%s \ngot \n%s", expectedRaw, resultRaw)
}
})
}
}
@@ -146,11 +149,12 @@ func TestIndexIsValid(t *testing.T) {
}
for _, s := range scenarios {
result := s.index.IsValid()
if result != s.expected {
t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
}
t.Run(s.name, func(t *testing.T) {
result := s.index.IsValid()
if result != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, result)
}
})
}
}
@@ -218,10 +222,93 @@ func TestIndexBuild(t *testing.T) {
}
for _, s := range scenarios {
result := s.index.Build()
t.Run(s.name, func(t *testing.T) {
result := s.index.Build()
if result != s.expected {
t.Fatalf("Expected \n%v \ngot \n%v", s.expected, result)
}
})
}
}
if result != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, result)
}
func TestHasSingleColumnUniqueIndex(t *testing.T) {
scenarios := []struct {
name string
column string
indexes []string
expected bool
}{
{
"empty indexes",
"test",
nil,
false,
},
{
"empty column",
"",
[]string{
"CREATE UNIQUE INDEX `index1` ON `example` (`test`)",
},
false,
},
{
"mismatched column",
"test",
[]string{
"CREATE UNIQUE INDEX `index1` ON `example` (`test2`)",
},
false,
},
{
"non unique index",
"test",
[]string{
"CREATE INDEX `index1` ON `example` (`test`)",
},
false,
},
{
"matching columnd and unique index",
"test",
[]string{
"CREATE UNIQUE INDEX `index1` ON `example` (`test`)",
},
true,
},
{
"multiple columns",
"test",
[]string{
"CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)",
},
false,
},
{
"multiple indexes",
"test",
[]string{
"CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)",
"CREATE UNIQUE INDEX `index2` ON `example` (`test`)",
},
true,
},
{
"partial unique index",
"test",
[]string{
"CREATE UNIQUE INDEX `index` ON `example` (`test`) where test != ''",
},
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := dbutils.HasSingleColumnUniqueIndex(s.column, s.indexes)
if result != s.expected {
t.Fatalf("Expected %v got %v", s.expected, result)
}
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package dbutils
import (
"fmt"
"strings"
)
// JsonEach returns JSON_EACH SQLite string expression with
// some normalizations for non-json columns.
func JsonEach(column string) string {
return fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END)`,
column, column, column,
)
}
// JsonArrayLength returns JSON_ARRAY_LENGTH SQLite string expression
// with some normalizations for non-json columns.
//
// It works with both json and non-json column values.
//
// Returns 0 for empty string or NULL column values.
func JsonArrayLength(column string) string {
return fmt.Sprintf(
`json_array_length(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE (CASE WHEN [[%s]] = '' OR [[%s]] IS NULL THEN json_array() ELSE json_array([[%s]]) END) END)`,
column, column, column, column, column,
)
}
// JsonExtract returns a JSON_EXTRACT SQLite string expression with
// some normalizations for non-json columns.
func JsonExtract(column string, path string) string {
// prefix the path with dot if it is not starting with array notation
if path != "" && !strings.HasPrefix(path, "[") {
path = "." + path
}
return fmt.Sprintf(
// note: the extra object wrapping is needed to workaround the cases where a json_extract is used with non-json columns.
"(CASE WHEN json_valid([[%s]]) THEN JSON_EXTRACT([[%s]], '$%s') ELSE JSON_EXTRACT(json_object('pb', [[%s]]), '$.pb%s') END)",
column,
column,
path,
column,
path,
)
}
+66
View File
@@ -0,0 +1,66 @@
package dbutils_test
import (
"testing"
"github.com/pocketbase/pocketbase/tools/dbutils"
)
func TestJsonEach(t *testing.T) {
result := dbutils.JsonEach("a.b")
expected := "json_each(CASE WHEN json_valid([[a.b]]) THEN [[a.b]] ELSE json_array([[a.b]]) END)"
if result != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, result)
}
}
func TestJsonArrayLength(t *testing.T) {
result := dbutils.JsonArrayLength("a.b")
expected := "json_array_length(CASE WHEN json_valid([[a.b]]) THEN [[a.b]] ELSE (CASE WHEN [[a.b]] = '' OR [[a.b]] IS NULL THEN json_array() ELSE json_array([[a.b]]) END) END)"
if result != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, result)
}
}
func TestJsonExtract(t *testing.T) {
scenarios := []struct {
name string
column string
path string
expected string
}{
{
"empty path",
"a.b",
"",
"(CASE WHEN json_valid([[a.b]]) THEN JSON_EXTRACT([[a.b]], '$') ELSE JSON_EXTRACT(json_object('pb', [[a.b]]), '$.pb') END)",
},
{
"starting with array index",
"a.b",
"[1].a[2]",
"(CASE WHEN json_valid([[a.b]]) THEN JSON_EXTRACT([[a.b]], '$[1].a[2]') ELSE JSON_EXTRACT(json_object('pb', [[a.b]]), '$.pb[1].a[2]') END)",
},
{
"starting with key",
"a.b",
"a.b[2].c",
"(CASE WHEN json_valid([[a.b]]) THEN JSON_EXTRACT([[a.b]], '$.a.b[2].c') ELSE JSON_EXTRACT(json_object('pb', [[a.b]]), '$.pb.a.b[2].c') END)",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := dbutils.JsonExtract(s.column, s.path)
if result != s.expected {
t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result)
}
})
}
}
+34 -5
View File
@@ -10,7 +10,6 @@ import (
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
@@ -26,7 +25,7 @@ type FileReader interface {
// File defines a single file [io.ReadSeekCloser] resource.
//
// The file could be from a local path, multipipart/formdata header, etc.
// The file could be from a local path, multipart/form-data header, etc.
type File struct {
Reader FileReader
Name string
@@ -172,12 +171,16 @@ var extInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`)
func normalizeName(fr FileReader, name string) string {
// extension
// ---
originalExt := filepath.Ext(name)
originalExt := extractExtension(name)
cleanExt := extInvalidCharsRegex.ReplaceAllString(originalExt, "")
if cleanExt == "" {
// try to detect the extension from the file content
cleanExt, _ = detectExtension(fr)
}
if extLength := len(cleanExt); extLength > 20 {
// keep only the last 20 characters (it is multibyte safe after the regex replace)
cleanExt = "." + cleanExt[extLength-20:]
}
// name
// ---
@@ -198,15 +201,41 @@ func normalizeName(fr FileReader, name string) string {
)
}
// extractExtension extracts the extension (with leading dot) from name.
//
// This differ from filepath.Ext() by supporting double extensions (eg. ".tar.gz").
//
// Returns an empty string if no match is found.
//
// Example:
// extractExtension("test.txt") // .txt
// extractExtension("test.tar.gz") // .tar.gz
// extractExtension("test.a.tar.gz") // .tar.gz
func extractExtension(name string) string {
primaryDot := strings.LastIndex(name, ".")
if primaryDot == -1 {
return ""
}
// look for secondary extension
secondaryDot := strings.LastIndex(name[:primaryDot], ".")
if secondaryDot >= 0 {
return name[secondaryDot:]
}
return name[primaryDot:]
}
// detectExtension tries to detect the extension from file mime type.
func detectExtension(fr FileReader) (string, error) {
// try to detect the extension from the mime type
r, err := fr.Open()
if err != nil {
return "", err
}
defer r.Close()
mt, _ := mimetype.DetectReader(r)
mt, err := mimetype.DetectReader(r)
if err != nil {
return "", err
}
+35 -5
View File
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"github.com/labstack/echo/v5"
@@ -35,7 +37,7 @@ func TestNewFileFromPath(t *testing.T) {
if f.OriginalName != originalName {
t.Fatalf("Expected OriginalName %q, got %q", originalName, f.OriginalName)
}
if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err)
}
if f.Size != 73 {
@@ -69,7 +71,7 @@ func TestNewFileFromBytes(t *testing.T) {
if f.OriginalName != originalName {
t.Fatalf("Expected OriginalName %q, got %q", originalName, f.OriginalName)
}
if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err)
}
}
@@ -95,12 +97,12 @@ func TestNewFileFromMultipart(t *testing.T) {
}
originalNamePattern := regexp.QuoteMeta("tmpfile-") + `\w+` + regexp.QuoteMeta(".txt")
if match, _ := regexp.Match(originalNamePattern, []byte(f.OriginalName)); !match {
if match, err := regexp.Match(originalNamePattern, []byte(f.OriginalName)); !match {
t.Fatalf("Expected OriginalName to match %v, got %q (%v)", originalNamePattern, f.OriginalName, err)
}
normalizedNamePattern := regexp.QuoteMeta("tmpfile_") + `\w+\_\w{10}` + regexp.QuoteMeta(".txt")
if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err)
}
@@ -164,7 +166,7 @@ func TestNewFileFromUrlTimeout(t *testing.T) {
if f.OriginalName != originalName {
t.Fatalf("Expected OriginalName %q, got %q", originalName, f.OriginalName)
}
if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err)
}
if f.Size != 4 {
@@ -175,3 +177,31 @@ func TestNewFileFromUrlTimeout(t *testing.T) {
}
}
}
func TestFileNameNormalizations(t *testing.T) {
scenarios := []struct {
name string
pattern string
}{
{"", `^\w{10}_\w{10}\.txt$`},
{".png", `^\w{10}_\w{10}\.png$`},
{".tar.gz", `^\w{10}_\w{10}\.tar\.gz$`},
{"a.tar.gz", `^a\w{10}_\w{10}\.tar\.gz$`},
{"a.b.c.d.tar.gz", `^a_b_c_d_\w{10}\.tar\.gz$`},
{"abcd", `^abcd_\w{10}\.txt$`},
{"a b! c d . 456", `^a_b_c_d_\w{10}\.456$`}, // normalize spaces
{strings.Repeat("a", 101) + "." + strings.Repeat("b", 21), `^a{100}_\w{10}\.b{20}$`}, // name and extension length trim
}
for i, s := range scenarios {
t.Run(strconv.Itoa(i)+"_"+s.name, func(t *testing.T) {
f, err := filesystem.NewFileFromBytes([]byte("abc"), s.name)
if err != nil {
t.Fatal(err)
}
if match, err := regexp.Match(s.pattern, []byte(f.Name)); !match {
t.Fatalf("Expected Name to match %v, got %q (%v)", s.pattern, f.Name, err)
}
})
}
}
+51 -19
View File
@@ -8,24 +8,25 @@ import (
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
"github.com/pocketbase/pocketbase/tools/list"
"gocloud.dev/blob"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/s3blob"
)
var gcpIgnoreHeaders = []string{"Accept-Encoding"}
type System struct {
ctx context.Context
bucket *blob.Bucket
@@ -44,19 +45,38 @@ func NewS3(
) (*System, error) {
ctx := context.Background() // default context
cred := credentials.NewStaticCredentials(accessKey, secretKey, "")
cred := credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Endpoint: aws.String(endpoint),
Credentials: cred,
S3ForcePathStyle: aws.Bool(s3ForcePathStyle),
})
cfg, err := config.LoadDefaultConfig(ctx,
config.WithCredentialsProvider(cred),
config.WithRegion(region),
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
// ensure that the endpoint has url scheme for
// backward compatibility with v1 of the aws sdk
prefixedEndpoint := endpoint
if !strings.Contains(endpoint, "://") {
prefixedEndpoint = "https://" + endpoint
}
return aws.Endpoint{URL: prefixedEndpoint, SigningRegion: region}, nil
})),
)
if err != nil {
return nil, err
}
bucket, err := s3blob.OpenBucket(ctx, sess, bucketName, nil)
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = s3ForcePathStyle
// Google Cloud Storage alters the Accept-Encoding header,
// which breaks the v2 request signature
// (https://github.com/aws/aws-sdk-go-v2/issues/1816)
if strings.Contains(endpoint, "storage.googleapis.com") {
ignoreSigningHeaders(o, gcpIgnoreHeaders)
}
})
bucket, err := OpenBucketV2(ctx, client, bucketName, nil)
if err != nil {
return nil, err
}
@@ -255,16 +275,25 @@ func (s *System) Delete(fileKey string) error {
}
// DeletePrefix deletes everything starting with the specified prefix.
//
// The prefix could be subpath (ex. "/a/b/") or filename prefix (ex. "/a/b/file_").
func (s *System) DeletePrefix(prefix string) []error {
failed := []error{}
if prefix == "" {
failed = append(failed, errors.New("Prefix mustn't be empty."))
failed = append(failed, errors.New("prefix mustn't be empty"))
return failed
}
dirsMap := map[string]struct{}{}
dirsMap[prefix] = struct{}{}
var isPrefixDir bool
// treat the prefix as directory only if it ends with trailing slash
if strings.HasSuffix(prefix, "/") {
isPrefixDir = true
dirsMap[strings.TrimRight(prefix, "/")] = struct{}{}
}
// delete all files with the prefix
// ---
@@ -282,8 +311,11 @@ func (s *System) DeletePrefix(prefix string) []error {
if err := s.Delete(obj.Key); err != nil {
failed = append(failed, err)
} else {
dirsMap[path.Dir(obj.Key)] = struct{}{}
} else if isPrefixDir {
slashIdx := strings.LastIndex(obj.Key, "/")
if slashIdx > -1 {
dirsMap[obj.Key[:slashIdx]] = struct{}{}
}
}
}
// ---
@@ -400,7 +432,7 @@ var ThumbSizeRegex = regexp.MustCompile(`^(\d+)x(\d+)(t|b|f)?$`)
func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) error {
sizeParts := ThumbSizeRegex.FindStringSubmatch(thumbSize)
if len(sizeParts) != 4 {
return errors.New("Thumb size must be in WxH, WxHt, WxHb or WxHf format.")
return errors.New("thumb size must be in WxH, WxHt, WxHb or WxHf format")
}
width, _ := strconv.Atoi(sizeParts[1])
@@ -408,7 +440,7 @@ func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) err
resizeType := sizeParts[3]
if width == 0 && height == 0 {
return errors.New("Thumb width and height cannot be zero at the same time.")
return errors.New("thumb width and height cannot be zero at the same time")
}
// fetch the original
+40 -3
View File
@@ -101,7 +101,7 @@ func TestFileSystemDelete(t *testing.T) {
}
}
func TestFileSystemDeletePrefix(t *testing.T) {
func TestFileSystemDeletePrefixWithoutTrailingSlash(t *testing.T) {
dir := createTestDir(t)
defer os.RemoveAll(dir)
@@ -115,7 +115,7 @@ func TestFileSystemDeletePrefix(t *testing.T) {
t.Fatal("Expected error, got nil", errs)
}
if errs := fs.DeletePrefix("missing/"); len(errs) != 0 {
if errs := fs.DeletePrefix("missing"); len(errs) != 0 {
t.Fatalf("Not existing prefix shouldn't error, got %v", errs)
}
@@ -123,13 +123,50 @@ func TestFileSystemDeletePrefix(t *testing.T) {
t.Fatalf("Expected nil, got errors %v", errs)
}
// ensure that the test/ files are deleted
// ensure that the test/* files are deleted
if exists, _ := fs.Exists("test/sub1.txt"); exists {
t.Fatalf("Expected test/sub1.txt to be deleted")
}
if exists, _ := fs.Exists("test/sub2.txt"); exists {
t.Fatalf("Expected test/sub2.txt to be deleted")
}
// the test directory should remain since the prefix didn't have trailing slash
if _, err := os.Stat(filepath.Join(dir, "test")); os.IsNotExist(err) {
t.Fatal("Expected the prefix dir to remain")
}
}
func TestFileSystemDeletePrefixWithTrailingSlash(t *testing.T) {
dir := createTestDir(t)
defer os.RemoveAll(dir)
fs, err := filesystem.NewLocal(dir)
if err != nil {
t.Fatal(err)
}
defer fs.Close()
if errs := fs.DeletePrefix("missing/"); len(errs) != 0 {
t.Fatalf("Not existing prefix shouldn't error, got %v", errs)
}
if errs := fs.DeletePrefix("test/"); len(errs) != 0 {
t.Fatalf("Expected nil, got errors %v", errs)
}
// ensure that the test/* files are deleted
if exists, _ := fs.Exists("test/sub1.txt"); exists {
t.Fatalf("Expected test/sub1.txt to be deleted")
}
if exists, _ := fs.Exists("test/sub2.txt"); exists {
t.Fatalf("Expected test/sub2.txt to be deleted")
}
// the test directory should be also deleted since the prefix has trailing slash
if _, err := os.Stat(filepath.Join(dir, "test")); !os.IsNotExist(err) {
t.Fatal("Expected the prefix dir to be deleted")
}
}
func TestFileSystemUploadMultipart(t *testing.T) {
@@ -0,0 +1,72 @@
package filesystem
import (
"context"
"fmt"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go/middleware"
smithyhttp "github.com/aws/smithy-go/transport/http"
)
// ignoreSigningHeaders excludes the listed headers
// from the request signing because some providers may alter them.
//
// See https://github.com/aws/aws-sdk-go-v2/issues/1816.
func ignoreSigningHeaders(o *s3.Options, headers []string) {
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
if err := stack.Finalize.Insert(ignoreHeaders(headers), "Signing", middleware.Before); err != nil {
return err
}
if err := stack.Finalize.Insert(restoreIgnored(), "Signing", middleware.After); err != nil {
return err
}
return nil
})
}
type ignoredHeadersKey struct{}
func ignoreHeaders(headers []string) middleware.FinalizeMiddleware {
return middleware.FinalizeMiddlewareFunc(
"IgnoreHeaders",
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return out, metadata, &v4.SigningError{Err: fmt.Errorf("(ignoreHeaders) unexpected request middleware type %T", in.Request)}
}
ignored := make(map[string]string, len(headers))
for _, h := range headers {
ignored[h] = req.Header.Get(h)
req.Header.Del(h)
}
ctx = middleware.WithStackValue(ctx, ignoredHeadersKey{}, ignored)
return next.HandleFinalize(ctx, in)
},
)
}
func restoreIgnored() middleware.FinalizeMiddleware {
return middleware.FinalizeMiddlewareFunc(
"RestoreIgnored",
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return out, metadata, &v4.SigningError{Err: fmt.Errorf("(restoreIgnored) unexpected request middleware type %T", in.Request)}
}
ignored, _ := middleware.GetStackValue(ctx, ignoredHeadersKey{}).(map[string]string)
for k, v := range ignored {
req.Header.Set(k, v)
}
return next.HandleFinalize(ctx, in)
},
)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -74,5 +74,5 @@ func findSendmailPath() (string, error) {
}
}
return "", errors.New("Failed to locate a sendmail executable path.")
return "", errors.New("failed to locate a sendmail executable path")
}
+2 -2
View File
@@ -68,8 +68,8 @@ func (s *Serializer) Serialize(c echo.Context, i any, indent string) error {
//
// Example:
//
// data := map[string]any{"a": 1, "b": 2, "c": map[string]any{"c1": 11, "c2": 22}}
// PickFields(data, "a,c.c1") // map[string]any{"a": 1, "c": map[string]any{"c1": 11}}
// data := map[string]any{"a": 1, "b": 2, "c": map[string]any{"c1": 11, "c2": 22}}
// PickFields(data, "a,c.c1") // map[string]any{"a": 1, "c": map[string]any{"c1": 11}}
func PickFields(data any, rawFields string) (any, error) {
parsedFields, err := parseFields(rawFields)
if err != nil {
+45 -6
View File
@@ -12,6 +12,36 @@ import (
"github.com/spf13/cast"
)
// MultipartJsonKey is the key for the special multipart/form-data
// handling allowing reading serialized json payload without normalization.
const MultipartJsonKey string = "@jsonPayload"
// MultiBinder is similar to [echo.DefaultBinder] but uses slightly different
// application/json and multipart/form-data bind methods to accommodate better
// the PocketBase router needs.
type MultiBinder struct{}
// Bind implements the [Binder.Bind] method.
//
// Bind is almost identical to [echo.DefaultBinder.Bind] but uses the
// [rest.BindBody] function for binding the request body.
func (b *MultiBinder) Bind(c echo.Context, i interface{}) (err error) {
if err := echo.BindPathParams(c, i); err != nil {
return err
}
// Only bind query parameters for GET/DELETE/HEAD to avoid unexpected behavior with destination struct binding from body.
// For example a request URL `&id=1&lang=en` with body `{"id":100,"lang":"de"}` would lead to precedence issues.
method := c.Request().Method
if method == http.MethodGet || method == http.MethodDelete || method == http.MethodHead {
if err = echo.BindQueryParams(c, i); err != nil {
return err
}
}
return BindBody(c, i)
}
// BindBody binds request body content to i.
//
// This is similar to `echo.BindBody()`, but for JSON requests uses
@@ -43,8 +73,8 @@ func BindBody(c echo.Context, i any) error {
func CopyJsonBody(r *http.Request, i any) error {
body := r.Body
// this usually shouldn't be needed because the Server calls close for us
// but we are changing the request body with a new reader
// this usually shouldn't be needed because the Server calls close
// for us but we are changing the request body with a new reader
defer body.Close()
limitReader := io.LimitReader(body, DefaultMaxMemory)
@@ -62,10 +92,8 @@ func CopyJsonBody(r *http.Request, i any) error {
return err
}
// This is temp hotfix for properly binding multipart/form-data array values
// when a map destination is used.
//
// It should be replaced with echo.BindBody(c, i) once the issue is fixed in echo.
// Custom multipart/form-data binder that implements an additional handling like
// loading a serialized json payload or properly scan array values when a map destination is used.
func bindFormData(c echo.Context, i any) error {
if i == nil {
return nil
@@ -80,6 +108,13 @@ func bindFormData(c echo.Context, i any) error {
return nil
}
// special case to allow submitting json without normalization
// alongside the other multipart/form-data values
jsonPayloadValues := values[MultipartJsonKey]
for _, payload := range jsonPayloadValues {
json.Unmarshal([]byte(payload), i)
}
rt := reflect.TypeOf(i).Elem()
// map
@@ -87,6 +122,10 @@ func bindFormData(c echo.Context, i any) error {
rv := reflect.ValueOf(i).Elem()
for k, v := range values {
if k == MultipartJsonKey {
continue
}
if total := len(v); total == 1 {
rv.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(normalizeMultipartValue(v[0])))
} else {
+48 -7
View File
@@ -13,6 +13,46 @@ import (
"github.com/pocketbase/pocketbase/tools/rest"
)
func TestMultiBinderBind(t *testing.T) {
binder := rest.MultiBinder{}
req := httptest.NewRequest(http.MethodGet, "/test?query=123", strings.NewReader(`{"body":"456"}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
e := echo.New()
e.Any("/:name", func(c echo.Context) error {
// bind twice to ensure that the json body reader copy is invoked
for i := 0; i < 2; i++ {
data := struct {
Name string `param:"name"`
Query string `query:"query"`
Body string `form:"body"`
}{}
if err := binder.Bind(c, &data); err != nil {
t.Fatal(err)
}
if data.Name != "test" {
t.Fatalf("Expected Name %q, got %q", "test", data.Name)
}
if data.Query != "123" {
t.Fatalf("Expected Query %q, got %q", "123", data.Query)
}
if data.Body != "456" {
t.Fatalf("Expected Body %q, got %q", "456", data.Body)
}
}
return nil
})
e.ServeHTTP(rec, req)
}
func TestBindBody(t *testing.T) {
scenarios := []struct {
body io.Reader
@@ -41,16 +81,17 @@ func TestBindBody(t *testing.T) {
{
strings.NewReader(
url.Values{
"string": []string{"str"},
"stings": []string{"str1", "str2", ""},
"number": []string{"-123"},
"numbers": []string{"123", "456.789"},
"bool": []string{"true"},
"bools": []string{"true", "false"},
"string": []string{"str"},
"stings": []string{"str1", "str2", ""},
"number": []string{"-123"},
"numbers": []string{"123", "456.789"},
"bool": []string{"true"},
"bools": []string{"true", "false"},
rest.MultipartJsonKey: []string{`invalid`, `{"a":123}`, `{"b":456}`},
}.Encode(),
),
echo.MIMEApplicationForm,
`{"bool":true,"bools":[true,false],"number":-123,"numbers":[123,456.789],"stings":["str1","str2",""],"string":"str"}`,
`{"a":123,"b":456,"bool":true,"bools":[true,false],"number":-123,"numbers":[123,456.789],"stings":["str1","str2",""],"string":"str"}`,
false,
},
}

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