Compare commits

...
254 Commits
Author SHA1 Message Date
Gani Georgiev 671a6a3aed added manual WAL checkpoints before creating the zip backup to minimize copying unnecessary data 2024-11-17 12:44:09 +02:00
Gani Georgiev dbb0b6b2d2 [#5861] refresh the old collections in the UI import page on successful import submission 2024-11-14 14:42:58 +02:00
Gani Georgiev 0d34c00d2e added randomized trottle for failed list filter requests 2024-11-14 14:31:17 +02:00
Gani Georgiev 8144ad1dda updated ui/dist 2024-11-12 21:45:19 +02:00
Gani Georgiev 9a9b667d4d [#5845] delete new uploaded record files in case of DB persist error 2024-11-12 21:43:40 +02:00
Gani Georgiev 26ef0c697c registered a default http.Server.ErrorLog handler 2024-11-03 12:26:42 +02:00
Gani Georgiev 08793ee99f fixed error message typo 2024-11-03 12:06:22 +02:00
Gani Georgiev 75d1c0bc18 updated changelog and jsvm types 2024-11-03 12:01:47 +02:00
Gani Georgiev 060a659f83 [#5789] updated the hooks watcher to account for the case when hooksDir is a symlink 2024-11-03 11:52:13 +02:00
Gani Georgiev caaac6b145 added topic length validator to avoid relying solely on the body limit for more user friendly error message 2024-11-03 11:11:51 +02:00
Gani Georgiev cd14714cf3 updated npm deps 2024-11-03 11:11:23 +02:00
Gani Georgiev 0b30435183 updated go.mod 2024-10-18 17:09:55 +03:00
Gani Georgiev c4b1e2d55e updated changelog 2024-10-18 17:08:02 +03:00
Gani Georgiev 58a0b28664 updated aws-sdk-go-v2 and moved the trimmed s3 driver in internal to preven getting included in the jsvm types 2024-10-18 17:07:53 +03:00
Gani Georgiev 2ff2cd874c updated ui/dist 2024-10-18 16:35:04 +03:00
Gani Georgiev 3dd644c15c updated changelog and min Go version 2024-10-18 16:33:34 +03:00
Gani Georgiev cffbe83399 backported JSVM toString 2024-10-18 16:32:43 +03:00
Gani Georgiev e18ea88113 updated ghupdate to prevent downloading unnecessary v0.23+ since it contains breaking changes 2024-10-18 16:06:39 +03:00
Gani Georgiev 3786e0816d added Instagram OAuth2 provider deprecation log 2024-10-18 16:05:44 +03:00
Gani Georgiev cbc88d7a01 backported some of the v0.23.0 form validators, fixes and tests 2024-10-18 16:03:49 +03:00
Gani Georgiev e92d3cccd0 updated jsvm types 2024-10-18 14:23:38 +03:00
Gani Georgiev a1d9a9bbc9 updated go deps and the min Go version in the README 2024-10-12 11:54:16 +03:00
Gani Georgiev ad92992324 [#5541] lock the logs database during backup 2024-09-18 06:24:22 +03:00
Gani Georgiev 5547c0dede updated go deps 2024-08-27 21:46:25 +03:00
Gani Georgiev b7732098e0 [##5398] fixed the admin ui isEmpty check 2024-08-27 21:19:18 +03:00
Gani Georgiev 498a21b020 include the 'user' object in the Apple.FetchRawUserData result 2024-08-12 22:28:23 +03:00
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 9419d1928a [#4160] fixed the Admin UI auto indexes update when renaming fields with a common prefix 2024-01-16 12:50:44 +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 931f6bc0cb updated go deps 2024-01-13 11:36:22 +02:00
Gani Georgiev 2e3ae1b60a [#4145] fixed JSVM types generation for functions with omitted arg types 2024-01-13 11:28:15 +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 eb695cc6d3 updated go crypto and other go deps 2024-01-05 20:48:15 +02:00
Gani Georgiev 28d15e86eb fixed optional migration condition
note: practically even the previous version should work ok because the json field didn't have previous options anyway and if it was nil the migration will fail
2024-01-05 20:35:09 +02:00
Gani Georgiev b033109654 synced with master 2024-01-04 21:26:55 +02:00
Gani Georgiev d0352aa3f9 [#4079] fixed popup searchbar css styles to prevent hiding the additional controls 2024-01-04 16:20:47 +02:00
Gani Georgiev 3b7d0e84f6 synced with master 2024-01-03 17:00:24 +02:00
Gani Georgiev 592b7e85c6 specify the exact license and changelog files to include in the release archive 2024-01-03 15:04:34 +02:00
Gani Georgiev 3792d44c35 updated changelog 2024-01-03 14:25:51 +02:00
Gani Georgiev a021fcaa75 [#4072] added non-json value dummy object wrap normalization 2024-01-03 14:16:12 +02:00
Gani Georgiev d123e19e61 synced with master 2024-01-03 12:46:49 +02:00
Gani Georgiev 982f876a93 updated jsvm types 2024-01-03 11:08:30 +02:00
Gani Georgiev 1fcc2d8683 updated CHANGELOG and added t.Parallel to some of the tests 2024-01-03 10:58:25 +02:00
Gani Georgiev 4f2492290e [#4068] fixed the json field query comparisons to work correctly with plain JSON values 2024-01-03 10:43:46 +02:00
Gani Georgiev 8f625daa2f updated some of the tests to use t.Parallel 2024-01-03 04:30:20 +02:00
Gani Georgiev 0599955676 sort cascadeDelete refs for deterministic tests output 2024-01-03 04:29:30 +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 c4116e3a7d added jsvm sleep binding 2023-12-29 23:29:00 +02:00
Gani Georgiev 64cee264f0 bumped app version 2023-12-29 22:00:47 +02:00
Gani Georgiev 0ae9f24a81 updated fields query param examples for the auth actions 2023-12-29 21:47:04 +02:00
Gani Georgiev 6d942c7d30 docs fixes commits from develop 2023-12-29 21:25:32 +02:00
Gani Georgiev 705d7f48e7 synced with master 2023-12-29 10:00:06 +02:00
Gani Georgiev 9f67c5d563 regenerated jsvm types 2023-12-29 09:58:21 +02:00
mookrs 1ac7330e0b [#4043] fixed typos in godoc comments 2023-12-29 09:56:36 +02:00
Gani Georgiev e73b3a32d2 synced with master 2023-12-27 10:50:48 +02:00
Gani Georgiev 461886f64e fixed the monospace font loading in the Admin UI 2023-12-27 10:47:18 +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 8d97eb0769 [#4022] fixed multi-line text paste in the Admin UI search bar 2023-12-24 11:13:17 +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
Gani Georgiev 1208edec92 regenerated jsvm types 2023-12-17 00:20:04 +02:00
Gani Georgiev 5555e63116 updated dev debug log text message color to be slightly more visible 2023-12-17 00:17:29 +02:00
Gani Georgiev d6569b445c added timestamp to the generated JSVM types file to prevent creating it every time on app startup 2023-12-16 23:20:38 +02:00
Gani Georgiev 0b4f3b2adf combine the logs listing label span 2023-12-16 23:16:07 +02:00
Gani Georgiev 8bd968ed06 split changelog in chunks 2023-12-16 18:22:18 +02:00
Gani Georgiev 5c961f8537 [#3918] added --dev flag, dev log printer and some minor log UI enhacements 2023-12-16 18:15:36 +02:00
Gani Georgiev bf5eba0384 added bool to the view query sql syntax highlighter and autocompletion 2023-12-13 09:07:18 +02:00
Gani Georgiev c213d9313e fixed changelog typo 2023-12-12 19:48:28 +02:00
Gani Georgiev b31cf984a5 [#3930] replaced the default 100ms api tests timeout in favor of new ApiScenario.Timeout field 2023-12-12 19:46:58 +02:00
Gani Georgiev 8671debc35 removed the blank current time entry from the logs chart 2023-12-11 09:49:06 +02:00
Gani Georgiev b0f027d27a updated changelog formatting and temp moved the admin only rule checks to the record_helpers 2023-12-10 21:06:02 +02:00
391 changed files with 16193 additions and 9857 deletions

No files matched your search

-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.3'
go-version: '>=1.22.8'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
+2 -2
View File
@@ -51,8 +51,8 @@ archives:
builds: [build_noncgo]
format: zip
files:
- LICENSE*
- CHANGELOG*
- LICENSE.md
- CHANGELOG.md
checksum:
name_template: 'checksums.txt'
+438 -1407
View File
File diff suppressed because it is too large. Load diff
+1384
View File
File diff suppressed because it is too large. Load diff
+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.22+ (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.22+](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.
+18
View File
@@ -17,6 +17,8 @@ import (
)
func TestAdminAuthWithPassword(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "empty data",
@@ -119,6 +121,8 @@ func TestAdminAuthWithPassword(t *testing.T) {
}
func TestAdminRequestPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "empty data",
@@ -188,6 +192,8 @@ func TestAdminRequestPasswordReset(t *testing.T) {
}
func TestAdminConfirmPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "empty data",
@@ -277,6 +283,8 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
}
func TestAdminRefresh(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -350,6 +358,8 @@ func TestAdminRefresh(t *testing.T) {
}
func TestAdminsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -453,6 +463,8 @@ func TestAdminsList(t *testing.T) {
}
func TestAdminView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -508,6 +520,8 @@ func TestAdminView(t *testing.T) {
}
func TestAdminDelete(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -603,6 +617,8 @@ func TestAdminDelete(t *testing.T) {
}
func TestAdminCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized (while having at least 1 existing admin)",
@@ -757,6 +773,8 @@ func TestAdminCreate(t *testing.T) {
}
func TestAdminUpdate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
+12
View File
@@ -10,6 +10,8 @@ import (
)
func TestNewApiErrorWithRawData(t *testing.T) {
t.Parallel()
e := apis.NewApiError(
300,
"message_test",
@@ -33,6 +35,8 @@ func TestNewApiErrorWithRawData(t *testing.T) {
}
func TestNewApiErrorWithValidationData(t *testing.T) {
t.Parallel()
e := apis.NewApiError(
300,
"message_test",
@@ -66,6 +70,8 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
}
func TestNewNotFoundError(t *testing.T) {
t.Parallel()
scenarios := []struct {
message string
data any
@@ -87,6 +93,8 @@ func TestNewNotFoundError(t *testing.T) {
}
func TestNewBadRequestError(t *testing.T) {
t.Parallel()
scenarios := []struct {
message string
data any
@@ -108,6 +116,8 @@ func TestNewBadRequestError(t *testing.T) {
}
func TestNewForbiddenError(t *testing.T) {
t.Parallel()
scenarios := []struct {
message string
data any
@@ -129,6 +139,8 @@ func TestNewForbiddenError(t *testing.T) {
}
func TestNewUnauthorizedError(t *testing.T) {
t.Parallel()
scenarios := []struct {
message string
data any
+12
View File
@@ -17,6 +17,8 @@ import (
)
func TestBackupsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -84,6 +86,8 @@ func TestBackupsList(t *testing.T) {
}
func TestBackupsCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -198,6 +202,8 @@ func TestBackupsCreate(t *testing.T) {
}
func TestBackupsUpload(t *testing.T) {
t.Parallel()
// create dummy form data bodies
type body struct {
buffer io.Reader
@@ -330,6 +336,8 @@ func TestBackupsUpload(t *testing.T) {
}
func TestBackupsDownload(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -485,6 +493,8 @@ func TestBackupsDownload(t *testing.T) {
}
func TestBackupsDelete(t *testing.T) {
t.Parallel()
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
files, err := getBackupFiles(app)
if err != nil {
@@ -645,6 +655,8 @@ func TestBackupsDelete(t *testing.T) {
}
func TestBackupsRestore(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
+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()))
+61 -40
View File
@@ -11,10 +11,13 @@ 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"
)
func Test404(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Method: http.MethodGet,
@@ -53,6 +56,8 @@ func Test404(t *testing.T) {
}
func TestCustomRoutesAndErrorsHandling(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "custom route",
@@ -142,6 +147,8 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
}
func TestRemoveTrailingSlashMiddleware(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "non /api/* route (exact match)",
@@ -214,13 +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",
@@ -234,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)
@@ -248,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 {
@@ -308,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 {
@@ -316,6 +335,8 @@ func TestEagerRequestInfoCache(t *testing.T) {
}
func TestErrorHandler(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "apis.ApiError",
+14 -1
View File
@@ -17,6 +17,8 @@ import (
)
func TestCollectionsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -125,6 +127,8 @@ func TestCollectionsList(t *testing.T) {
}
func TestCollectionView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -193,6 +197,8 @@ func TestCollectionView(t *testing.T) {
}
func TestCollectionDelete(t *testing.T) {
t.Parallel()
ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
@@ -338,6 +344,8 @@ func TestCollectionDelete(t *testing.T) {
}
func TestCollectionCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -715,6 +723,8 @@ func TestCollectionCreate(t *testing.T) {
}
func TestCollectionUpdate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -883,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",
@@ -1082,6 +1093,8 @@ func TestCollectionUpdate(t *testing.T) {
}
func TestCollectionsImport(t *testing.T) {
t.Parallel()
totalCollections := 11
scenarios := []tests.ApiScenario{
+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
View File
@@ -20,6 +20,8 @@ import (
)
func TestFileToken(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -92,6 +94,8 @@ func TestFileToken(t *testing.T) {
}
func TestFileDownload(t *testing.T) {
t.Parallel()
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
@@ -391,6 +395,8 @@ func TestFileDownload(t *testing.T) {
}
func TestConcurrentThumbsGeneration(t *testing.T) {
t.Parallel()
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
+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."
+9 -1
View File
@@ -8,9 +8,17 @@ import (
)
func TestHealthAPI(t *testing.T) {
t.Parallel()
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,
+6
View File
@@ -9,6 +9,8 @@ import (
)
func TestLogsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -78,6 +80,8 @@ func TestLogsList(t *testing.T) {
}
func TestLogView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -136,6 +140,8 @@ func TestLogView(t *testing.T) {
}
func TestLogsStats(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
+14 -4
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
@@ -260,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)
}
@@ -315,7 +316,7 @@ func logRequest(app core.App, c echo.Context, err *ApiError) {
httpResponse := c.Response()
method := strings.ToUpper(httpRequest.Method)
status := httpResponse.Status
url := httpRequest.URL.RequestURI()
requestUri := httpRequest.URL.RequestURI()
// parse the request error
if err != nil {
@@ -336,7 +337,7 @@ func logRequest(app core.App, c echo.Context, err *ApiError) {
attrs = append(
attrs,
slog.String("url", url),
slog.String("url", requestUri),
slog.String("method", method),
slog.Int("status", status),
slog.String("auth", requestAuth),
@@ -355,7 +356,14 @@ func logRequest(app core.App, c echo.Context, err *ApiError) {
// don't block on logs write
routine.FireAndForget(func() {
message := method + " " + url
message := method + " "
if escaped, err := url.PathUnescape(requestUri); err == nil {
message += escaped
} else {
message += requestUri
}
if err != nil {
app.Logger().Error(message, attrs...)
} else {
@@ -394,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 {
+16
View File
@@ -10,6 +10,8 @@ import (
)
func TestRequireGuestOnly(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "valid record token",
@@ -104,6 +106,8 @@ func TestRequireGuestOnly(t *testing.T) {
}
func TestRequireRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -242,6 +246,8 @@ func TestRequireRecordAuth(t *testing.T) {
}
func TestRequireSameContextRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -358,6 +364,8 @@ func TestRequireSameContextRecordAuth(t *testing.T) {
}
func TestRequireAdminAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -452,6 +460,8 @@ func TestRequireAdminAuth(t *testing.T) {
}
func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest (while having at least 1 existing admin)",
@@ -571,6 +581,8 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
}
func TestRequireAdminOrRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -731,6 +743,8 @@ func TestRequireAdminOrRecordAuth(t *testing.T) {
}
func TestRequireAdminOrOwnerAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -869,6 +883,8 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
}
func TestLoadCollectionContext(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
+6 -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,
@@ -568,6 +569,10 @@ func (api *realtimeApi) canAccessRecord(
return true // no further checks needed
}
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
return false
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false)
+4
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -22,6 +23,7 @@ func TestRealtimeConnect(t *testing.T) {
{
Method: http.MethodGet,
Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedContent: []string{
`id:`,
@@ -44,6 +46,7 @@ func TestRealtimeConnect(t *testing.T) {
Name: "PB_CONNECT interrupt",
Method: http.MethodGet,
Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
@@ -68,6 +71,7 @@ func TestRealtimeConnect(t *testing.T) {
Name: "Skipping/ignoring messages",
Method: http.MethodGet,
Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
+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)
}
+359 -87
View File
@@ -17,6 +17,8 @@ import (
)
func TestRecordAuthMethodsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
@@ -71,6 +73,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
}
func TestRecordAuthWithPassword(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "invalid body format",
@@ -233,6 +237,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
@@ -300,6 +307,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
{
@@ -324,6 +334,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
@@ -356,6 +369,8 @@ func TestRecordAuthWithPassword(t *testing.T) {
}
func TestRecordAuthRefresh(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -483,6 +498,8 @@ func TestRecordAuthRefresh(t *testing.T) {
}
func TestRecordAuthRequestPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "not an auth collection",
@@ -568,6 +585,8 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
}
func TestRecordAuthConfirmPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "empty data",
@@ -634,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(`{
@@ -649,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",
@@ -681,6 +826,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
}
func TestRecordAuthRequestVerification(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "not an auth collection",
@@ -774,6 +921,8 @@ func TestRecordAuthRequestVerification(t *testing.T) {
}
func TestRecordAuthConfirmVerification(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "empty data",
@@ -902,6 +1051,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
}
func TestRecordAuthRequestEmailChange(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -1004,6 +1155,8 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
}
func TestRecordAuthConfirmEmailChange(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "not an auth collection",
@@ -1124,6 +1277,8 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
}
func TestRecordAuthListExternalsAuths(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -1224,6 +1379,8 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
}
func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -1353,114 +1510,229 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
}
func TestRecordAuthOAuth2Redirect(t *testing.T) {
c1 := subscriptions.NewDefaultClient()
t.Parallel()
c2 := subscriptions.NewDefaultClient()
c2.Subscribe("@oauth2")
clientStubs := make([]map[string]subscriptions.Client, 0, 10)
c3 := subscriptions.NewDefaultClient()
c3.Subscribe("test1", "@oauth2")
for i := 0; i < 10; i++ {
c1 := subscriptions.NewDefaultClient()
c4 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2")
c2 := subscriptions.NewDefaultClient()
c2.Subscribe("@oauth2")
c5 := subscriptions.NewDefaultClient()
c5.Subscribe("@oauth2")
c5.Discard()
c3 := subscriptions.NewDefaultClient()
c3.Subscribe("test1", "@oauth2")
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)
c4 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2")
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")
}
},
},
}
+51 -23
View File
@@ -1,10 +1,12 @@
package apis
import (
cryptoRand "crypto/rand"
"fmt"
"log/slog"
"math/big"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -43,13 +45,13 @@ func (api *recordApi) list(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
requestInfo := RequestInfo(c)
// forbid users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil {
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
return err
}
requestInfo := RequestInfo(c)
if requestInfo.Admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
@@ -72,7 +74,13 @@ func (api *recordApi) list(c echo.Context) error {
records := []*models.Record{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
// note: in v0.23.0 this has been migrated as option check in the search.Provider
queryStr := c.QueryParams().Encode()
if len(queryStr) > 2048 {
return NewBadRequestError("query string is too large", nil)
}
result, err := searchProvider.ParseAndExec(queryStr, &records)
if err != nil {
return NewBadRequestError("", err)
}
@@ -92,10 +100,43 @@ func (api *recordApi) list(c echo.Context) error {
api.app.Logger().Debug("Failed to enrich list records", slog.String("error", err.Error()))
}
// note: in v0.23.0 this is combined with extra check for repeated attempts
//
// Add a randomized throttle in case of empty search filter attempts.
//
// This is just for extra precaution since security researches raised concern regarding the possibity of eventual
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
// This is by design and it is an accepted tradeoff between performance, usability and correctness.
//
// While technically the below doesn't fully guarantee protection against filter timing attacks, in practice combined with the network latency it makes them even less feasible.
// A properly configured rate limiter or individual fields Hidden checks are better suited if you are really concerned about eventual information disclosure by side-channel attacks.
//
// In all cases it doesn't really matter that much because it doesn't affect the builtin PocketBase security sensitive fields (e.g. password and tokenKey) since they
// are not client-side filterable and in the few places where they need to be compared against an external value, a constant time check is used.
if requestInfo.Admin == nil &&
(collection.ListRule != nil && *collection.ListRule != "") &&
(requestInfo.Query["filter"] != "") &&
len(e.Records) == 0 {
api.app.Logger().Debug("Randomized throttle because of failed filter search", "collectionId", collection.Id)
randomizedThrottle(100)
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
func randomizedThrottle(softMax int64) {
var timeout int64
randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(softMax))
if err == nil {
timeout = randRange.Int64()
} else {
timeout = softMax
}
time.Sleep(time.Duration(timeout) * time.Millisecond)
}
func (api *recordApi) view(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
@@ -186,6 +227,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
@@ -409,21 +455,3 @@ func (api *recordApi) delete(c echo.Context) error {
})
})
}
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // admins are allowed to query everything
}
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
forbiddenFields := []string{"@collection.", "@request."}
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
+157 -2
View File
@@ -14,9 +14,13 @@ 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) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
@@ -43,9 +47,16 @@ func TestRecordCrudList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection but with admin only filter/sort (aka. @collection)",
Name: "public collection but with admin only filter param (aka. @collection, @request, etc.)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'",
Url: "/api/collections/demo2/records?filter=%40collection.demo2.title='test1'",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection but with admin only sort param (aka. @collection, @request, etc.)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records?sort=@request.auth.title",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
@@ -486,6 +497,8 @@ func TestRecordCrudList(t *testing.T) {
}
func TestRecordCrudView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
@@ -765,6 +778,8 @@ func TestRecordCrudView(t *testing.T) {
}
func TestRecordCrudDelete(t *testing.T) {
t.Parallel()
ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
@@ -1008,6 +1023,8 @@ func TestRecordCrudDelete(t *testing.T) {
}
func TestRecordCrudCreate(t *testing.T) {
t.Parallel()
formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test",
}, "files")
@@ -1015,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",
@@ -1222,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,
@@ -1584,6 +1669,8 @@ func TestRecordCrudCreate(t *testing.T) {
}
func TestRecordCrudUpdate(t *testing.T) {
t.Parallel()
formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test",
}, "files")
@@ -1591,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",
@@ -1813,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,
+35 -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 != "" {
@@ -314,3 +315,31 @@ func hasAuthManageAccess(
return findErr == nil
}
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
var adminOnlyRuleFields = []string{"@collection.", "@request."}
// @todo consider moving the rules check to the RecordFieldResolver.
//
// checkForAdminOnlyRuleFields loosely checks and returns an error if
// the provided RequestInfo contains rule fields that only the admin can use.
func checkForAdminOnlyRuleFields(requestInfo *models.RequestInfo) error {
if requestInfo.Admin != nil || len(requestInfo.Query) == 0 {
return nil // admin or nothing to check
}
for _, param := range ruleQueryParams {
v, _ := requestInfo.Query[param].(string)
if v == "" {
continue
}
for _, field := range adminOnlyRuleFields {
if strings.Contains(v, field) {
return NewForbiddenError("Only admins can filter by "+field, nil)
}
}
}
return nil
}
+8 -2
View File
@@ -14,6 +14,8 @@ import (
)
func TestRequestInfo(t *testing.T) {
t.Parallel()
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
@@ -67,6 +69,8 @@ func TestRequestInfo(t *testing.T) {
}
func TestRecordAuthResponse(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -83,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)
}
@@ -104,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,
},
{
@@ -189,6 +193,8 @@ func TestRecordAuthResponse(t *testing.T) {
}
func TestEnrichRecords(t *testing.T) {
t.Parallel()
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+11
View File
@@ -159,6 +159,7 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
BaseContext: func(l net.Listener) context.Context {
return baseCtx
},
ErrorLog: log.New(&serverErrorLogWriter{app: app}, "", 0),
}
serveEvent := &core.ServeEvent{
@@ -275,3 +276,13 @@ func runMigrations(app core.App) error {
return nil
}
type serverErrorLogWriter struct {
app core.App
}
func (s *serverErrorLogWriter) Write(p []byte) (int, error) {
s.app.Logger().Debug(strings.TrimSpace(string(p)))
return len(p), nil
}
+16
View File
@@ -18,6 +18,8 @@ import (
)
func TestSettingsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -82,6 +84,8 @@ func TestSettingsList(t *testing.T) {
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -97,6 +101,8 @@ func TestSettingsList(t *testing.T) {
}
func TestSettingsSet(t *testing.T) {
t.Parallel()
validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{
@@ -165,6 +171,8 @@ func TestSettingsSet(t *testing.T) {
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -237,6 +245,8 @@ func TestSettingsSet(t *testing.T) {
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -278,6 +288,8 @@ func TestSettingsSet(t *testing.T) {
}
func TestSettingsTestS3(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -344,6 +356,8 @@ func TestSettingsTestS3(t *testing.T) {
}
func TestSettingsTestEmail(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -508,6 +522,8 @@ func TestSettingsTestEmail(t *testing.T) {
}
func TestGenerateAppleClientSecret(t *testing.T) {
t.Parallel()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
+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])
+2
View File
@@ -8,6 +8,8 @@ import (
)
func TestAdminCreateCommand(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+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
},
}
+5 -2
View File
@@ -59,6 +59,9 @@ type App interface {
// (used for settings encryption).
EncryptionEnv() string
// IsDev returns whether the app is in dev mode.
IsDev() bool
// Settings returns the loaded app settings.
Settings() *settings.Settings
@@ -288,14 +291,14 @@ type App interface {
// SSE client connection.
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
// OnRealtimeBeforeMessage hook is triggered right before sending
// OnRealtimeBeforeMessageSend hook is triggered right before sending
// an SSE message to a client.
//
// Returning [hook.StopPropagation] will prevent sending the message.
// Returning any other non-nil error will close the realtime connection.
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeMessage hook is triggered right after sending
// OnRealtimeAfterMessageSend hook is triggered right after sending
// an SSE message to a client.
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
+71 -23
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@@ -47,6 +48,7 @@ type BaseApp struct {
// @todo consider introducing a mutex to allow safe concurrent config changes during runtime
// configurable parameters
isDev bool
dataDir string
encryptionEnv string
dataMaxOpenConns int
@@ -174,6 +176,7 @@ type BaseApp struct {
// BaseAppConfig defines a BaseApp configuration option
type BaseAppConfig struct {
IsDev bool
DataDir string
EncryptionEnv string
DataMaxOpenConns int // default to 500
@@ -188,6 +191,7 @@ type BaseAppConfig struct {
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
isDev: config.IsDev,
dataDir: config.DataDir,
encryptionEnv: config.EncryptionEnv,
dataMaxOpenConns: config.DataMaxOpenConns,
@@ -458,6 +462,13 @@ func (app *BaseApp) EncryptionEnv() string {
return app.encryptionEnv
}
// IsDev returns whether the app is in dev mode.
//
// When enabled logs, executed sql statements, etc. are printed to the stderr.
func (app *BaseApp) IsDev() bool {
return app.isDev
}
// Settings returns the loaded app settings.
func (app *BaseApp) Settings() *settings.Settings {
return app.settings
@@ -590,8 +601,10 @@ func (app *BaseApp) RefreshSettings() error {
}
// reload handler level (if initialized)
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
h.SetLevel(slog.Level(app.settings.Logs.MinLevel))
if app.Logger() != nil {
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
h.SetLevel(app.getLoggerMinLevel())
}
}
return nil
@@ -1054,15 +1067,16 @@ func (app *BaseApp) initDataDB() error {
nonconcurrentDB.DB().SetMaxIdleConns(1)
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
// @todo benchmark whether it will have an impact if always enabled as TRACE log
// nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
// color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
// }
// concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
// nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
// color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
// }
// concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
if app.IsDev() {
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
}
app.dao = app.createDaoWithHooks(concurrentDB, nonconcurrentDB)
@@ -1148,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
@@ -1169,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 {
@@ -1176,15 +1215,19 @@ func (app *BaseApp) initLogger() error {
ticker := time.NewTicker(duration)
done := make(chan bool)
level := slog.LevelInfo
if app.Settings() != nil {
level = slog.Level(app.Settings().Logs.MinLevel)
}
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: level,
Level: app.getLoggerMinLevel(),
BatchSize: 200,
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
if app.IsDev() {
printLog(log)
// manually check the log level and skip if necessary
if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
return false
}
}
ticker.Reset(duration)
return app.Settings().Logs.MaxDays > 0
@@ -1216,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 {
@@ -1241,7 +1284,7 @@ func (app *BaseApp) initLogger() error {
for {
select {
case <-done:
handler.WriteAll(ctx)
return
case <-ticker.C:
handler.WriteAll(ctx)
}
@@ -1251,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
})
+19 -3
View File
@@ -72,9 +72,15 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
// ---
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// @todo consider experimenting with temp switching the readonly pragma after the db interface change
return archive.Create(app.DataDir(), tempPath, exclude...)
createErr := app.Dao().RunInTransaction(func(dataTXDao *daos.Dao) error {
return app.LogsDao().RunInTransaction(func(logsTXDao *daos.Dao) error {
// run manual checkpoint and truncate the WAL files
// (errors are ignored because it is not that important and the PRAGMA may not be supported by the used driver)
dataTXDao.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
logsTXDao.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
return archive.Create(app.DataDir(), tempPath, exclude...)
})
})
if createErr != nil {
return createErr
@@ -248,6 +254,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
+353 -57
View File
@@ -1,14 +1,25 @@
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"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"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) {
@@ -18,6 +29,7 @@ func TestNewBaseApp(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "test_env",
IsDev: true,
})
if app.dataDir != testDataDir {
@@ -28,6 +40,10 @@ func TestNewBaseApp(t *testing.T) {
t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir)
}
if !app.isDev {
t.Fatalf("expected isDev true, got %v", app.isDev)
}
if app.store == nil {
t.Fatal("expected store to be set, got nil")
}
@@ -132,6 +148,7 @@ func TestBaseAppGetters(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDev: true,
})
defer app.ResetBootstrapState()
@@ -163,6 +180,10 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv)
}
if app.isDev != app.IsDev() {
t.Fatalf("Expected app.IsDev %v, got %v", app.IsDev(), app.isDev)
}
if app.settings != app.Settings() {
t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
}
@@ -185,13 +206,11 @@ func TestBaseAppGetters(t *testing.T) {
}
func TestBaseAppNewMailClient(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
})
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
client1 := app.NewMailClient()
if val, ok := client1.(*mailer.Sendmail); !ok {
@@ -207,13 +226,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
}
func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
})
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
// local
local, localErr := app.NewFilesystem()
@@ -236,13 +253,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
}
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
})
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
// local
local, localErr := app.NewBackupsFilesystem()
@@ -265,49 +280,41 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
}
func TestBaseAppLoggerWrites(t *testing.T) {
testDataDir, err := os.MkdirTemp("", "logger_writes")
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(testDataDir)
defer cleanup()
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
const logsThreshold = 200
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
totalLogs := func(app App, t *testing.T) int {
var total int
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
if err != nil {
t.Fatalf("Failed to fetch total logs: %v", err)
}
return total
}
// init logs migrations
runner, err := migrate.NewRunner(app.LogsDB(), logs.LogsMigrations)
if err != nil {
t.Fatalf("Logs runner error: %v", err)
}
if _, err := runner.Up(); err != nil {
t.Fatalf("Logs migration execution error: %v", err)
}
threshold := 200
// 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")
}
@@ -321,25 +328,314 @@ 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 totalLogs(app App, t *testing.T) int {
var total int
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatalf("Failed to fetch total logs: %v", err)
t.Fatal(err)
}
defer cleanup()
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
if !ok {
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
}
return total
scenarios := []struct {
name string
isDev bool
level int
// level->enabled map
expectations map[int]bool
}{
{
"dev mode",
true,
4,
map[int]bool{
3: true,
4: true,
5: true,
},
},
{
"nondev mode",
false,
4,
map[int]bool{
3: false,
4: true,
5: true,
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app.isDev = s.isDev
app.Settings().Logs.MinLevel = s.level
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
if err := app.RefreshSettings(); err != nil {
t.Fatalf("Failed to refresh app settings: %v", err)
}
for level, enabled := range s.expectations {
if v := handler.Enabled(nil, slog.Level(level)); v != enabled {
t.Fatalf("Expected level %d Enabled() to be %v, got %v", level, enabled, v)
}
}
})
}
}
func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
testLogLevel := 4
app.Settings().Logs.MinLevel = testLogLevel
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
isDev bool
levels []int
printedLevels []int
persistedLevels []int
}{
{
"dev mode",
true,
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
[]int{testLogLevel, testLogLevel + 1},
},
{
"nondev mode",
false,
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
[]int{},
[]int{testLogLevel, testLogLevel + 1},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
var printedLevels []int
var persistedLevels []int
app.isDev = s.isDev
// trigger slog handler min level refresh
if err := app.RefreshSettings(); err != nil {
t.Fatal(err)
}
// track printed logs
originalPrintLog := printLog
defer func() {
printLog = originalPrintLog
}()
printLog = func(log *logger.Log) {
printedLevels = append(printedLevels, int(log.Level))
}
// track persisted logs
app.LogsDao().AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
l, ok := m.(*models.Log)
if ok {
persistedLevels = append(persistedLevels, l.Level)
}
return nil
}
// write and persist logs
for _, l := range s.levels {
app.Logger().Log(nil, slog.Level(l), "test")
}
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
if !ok {
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
}
if err := handler.WriteAll(nil); err != nil {
t.Fatalf("Failed to write all logs: %v", err)
}
// check persisted log levels
if len(s.persistedLevels) != len(persistedLevels) {
t.Fatalf("Expected persisted levels \n%v\ngot\n%v", s.persistedLevels, persistedLevels)
}
for _, l := range persistedLevels {
if !list.ExistInSlice(l, s.persistedLevels) {
t.Fatalf("Missing expected persisted level %v in %v", l, persistedLevels)
}
}
// check printed log levels
if len(s.printedLevels) != len(printedLevels) {
t.Fatalf("Expected printed levels \n%v\ngot\n%v", s.printedLevels, printedLevels)
}
for _, l := range printedLevels {
if !list.ExistInSlice(l, s.printedLevels) {
t.Fatalf("Missing expected printed level %v in %v", l, printedLevels)
}
}
})
}
}
// -------------------------------------------------------------------
// note: make sure to call `defer cleanup()` when the app is no longer needed.
func initTestBaseApp() (app *BaseApp, cleanup func(), err error) {
testDataDir, err := os.MkdirTemp("", "test_base_app")
if err != nil {
return nil, nil, err
}
cleanup = func() {
os.RemoveAll(testDataDir)
}
app = NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
initErr := func() error {
if err := app.Bootstrap(); err != nil {
return fmt.Errorf("bootstrap error: %w", err)
}
logsRunner, err := migrate.NewRunner(app.LogsDB(), logs.LogsMigrations)
if err != nil {
return fmt.Errorf("logsRunner error: %w", err)
}
if _, err := logsRunner.Up(); err != nil {
return fmt.Errorf("logsRunner migrations execution error: %w", err)
}
dataRunner, err := migrate.NewRunner(app.DB(), migrations.AppMigrations)
if err != nil {
return fmt.Errorf("logsRunner error: %w", err)
}
if _, err := dataRunner.Up(); err != nil {
return fmt.Errorf("dataRunner migrations execution error: %w", err)
}
return nil
}()
if initErr != nil {
cleanup()
return nil, nil, initErr
}
return app, cleanup, nil
}
+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
}
+67
View File
@@ -0,0 +1,67 @@
package core
import (
"fmt"
"log/slog"
"strings"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/spf13/cast"
)
var cachedColors = store.New[*color.Color](nil)
// getColor returns [color.Color] object and cache it (if not already).
func getColor(attrs ...color.Attribute) (c *color.Color) {
cacheKey := fmt.Sprint(attrs)
if c = cachedColors.Get(cacheKey); c == nil {
c = color.New(attrs...)
cachedColors.Set(cacheKey, c)
}
return
}
// printLog prints the provided log to the stderr.
// (note: defined as variable to overwriting in the tests)
var printLog = func(log *logger.Log) {
var str strings.Builder
switch log.Level {
case slog.LevelDebug:
str.WriteString(getColor(color.Bold, color.FgHiBlack).Sprint("DEBUG "))
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
case slog.LevelInfo:
str.WriteString(getColor(color.Bold, color.FgWhite).Sprint("INFO "))
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
case slog.LevelWarn:
str.WriteString(getColor(color.Bold, color.FgYellow).Sprint("WARN "))
str.WriteString(getColor(color.FgYellow).Sprint(log.Message))
case slog.LevelError:
str.WriteString(getColor(color.Bold, color.FgRed).Sprint("ERROR "))
str.WriteString(getColor(color.FgRed).Sprint(log.Message))
default:
str.WriteString(getColor(color.Bold, color.FgCyan).Sprintf("[%d] ", log.Level))
str.WriteString(getColor(color.FgCyan).Sprint(log.Message))
}
str.WriteString("\n")
if v, ok := log.Data["type"]; ok && cast.ToString(v) == "request" {
padding := 0
keys := []string{"error", "details"}
for _, k := range keys {
if v := log.Data[k]; v != nil {
str.WriteString(getColor(color.FgHiRed).Sprintf("%s└─ %v", strings.Repeat(" ", padding), v))
str.WriteString("\n")
padding += 3
}
}
} else if len(log.Data) > 0 {
str.WriteString(getColor(color.FgHiBlack).Sprintf("└─ %v", log.Data))
str.WriteString("\n")
}
fmt.Print(str.String())
}
+4 -4
View File
@@ -46,9 +46,9 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
return model, nil
}
// FindAdminByToken finds the admin associated with the provided JWT token.
// FindAdminByToken finds the admin associated with the provided JWT.
//
// Returns an error if the JWT token is invalid or expired.
// Returns an error if the JWT is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
// @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
@@ -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)
+16
View File
@@ -8,6 +8,8 @@ import (
)
func TestAdminQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -20,6 +22,8 @@ func TestAdminQuery(t *testing.T) {
}
func TestFindAdminById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -47,6 +51,8 @@ func TestFindAdminById(t *testing.T) {
}
func TestFindAdminByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -76,6 +82,8 @@ func TestFindAdminByEmail(t *testing.T) {
}
func TestFindAdminByToken(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -131,6 +139,8 @@ func TestFindAdminByToken(t *testing.T) {
}
func TestTotalAdmins(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -155,6 +165,8 @@ func TestTotalAdmins(t *testing.T) {
}
func TestIsAdminEmailUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -180,6 +192,8 @@ func TestIsAdminEmailUnique(t *testing.T) {
}
func TestDeleteAdmin(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -225,6 +239,8 @@ func TestDeleteAdmin(t *testing.T) {
}
func TestSaveAdmin(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+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
}
}
+4
View File
@@ -6,6 +6,8 @@ import (
)
func TestGetDefaultRetryInterval(t *testing.T) {
t.Parallel()
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i)
}
@@ -20,6 +22,8 @@ func TestGetDefaultRetryInterval(t *testing.T) {
}
func TestBaseLockRetry(t *testing.T) {
t.Parallel()
scenarios := []struct {
err error
failUntilAttempt int
+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
+52 -27
View File
@@ -16,6 +16,8 @@ import (
)
func TestCollectionQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -28,6 +30,8 @@ func TestCollectionQuery(t *testing.T) {
}
func TestFindCollectionsByType(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -63,6 +67,8 @@ func TestFindCollectionsByType(t *testing.T) {
}
func TestFindCollectionByNameOrId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
}
func TestIsCollectionNameUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -116,6 +124,8 @@ func TestIsCollectionNameUnique(t *testing.T) {
}
func TestFindCollectionReferences(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -143,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 {
@@ -164,6 +176,8 @@ func TestFindCollectionReferences(t *testing.T) {
}
func TestDeleteCollection(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -251,6 +265,8 @@ func TestDeleteCollection(t *testing.T) {
}
func TestSaveCollectionCreate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -297,6 +313,8 @@ func TestSaveCollectionCreate(t *testing.T) {
}
func TestSaveCollectionUpdate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -336,6 +354,8 @@ func TestSaveCollectionUpdate(t *testing.T) {
// indirect update of a field used in view should cause view(s) update
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -395,6 +415,8 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
}
func TestSaveCollectionViewWrapping(t *testing.T) {
t.Parallel()
viewName := "test_wrapping"
scenarios := []struct {
@@ -504,6 +526,8 @@ func TestSaveCollectionViewWrapping(t *testing.T) {
}
func TestImportCollections(t *testing.T) {
t.Parallel()
totalCollections := 11
scenarios := []struct {
@@ -734,7 +758,7 @@ func TestImportCollections(t *testing.T) {
"demo1": 15,
"demo2": 2,
"demo3": 2,
"demo4": 11,
"demo4": 13,
"demo5": 6,
"new_import": 1,
}
@@ -752,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)
}
})
}
}
+12
View File
@@ -9,6 +9,8 @@ import (
)
func TestExternalAuthQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -21,6 +23,8 @@ func TestExternalAuthQuery(t *testing.T) {
}
func TestFindAllExternalAuthsByRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -58,6 +62,8 @@ func TestFindAllExternalAuthsByRecord(t *testing.T) {
}
func TestFindFirstExternalAuthByExpr(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -89,6 +95,8 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
}
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -125,6 +133,8 @@ func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
}
func TestSaveExternalAuth(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -161,6 +171,8 @@ func TestSaveExternalAuth(t *testing.T) {
}
func TestDeleteExternalAuth(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+10
View File
@@ -12,6 +12,8 @@ import (
)
func TestLogQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -24,6 +26,8 @@ func TestLogQuery(t *testing.T) {
}
func TestFindLogById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -54,6 +58,8 @@ func TestFindLogById(t *testing.T) {
}
func TestLogsStats(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -75,6 +81,8 @@ func TestLogsStats(t *testing.T) {
}
func TestDeleteOldLogs(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -115,6 +123,8 @@ func TestDeleteOldLogs(t *testing.T) {
}
func TestSaveLog(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+10
View File
@@ -11,6 +11,8 @@ import (
)
func TestParamQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -23,6 +25,8 @@ func TestParamQuery(t *testing.T) {
}
func TestFindParamByKey(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -50,6 +54,8 @@ func TestFindParamByKey(t *testing.T) {
}
func TestSaveParam(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestSaveParam(t *testing.T) {
}
func TestSaveParamEncrypted(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -126,6 +134,8 @@ func TestSaveParamEncrypted(t *testing.T) {
}
func TestDeleteParam(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+32 -19
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"github.com/pocketbase/dbx"
@@ -39,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)
@@ -197,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.
@@ -409,9 +408,9 @@ func (dao *Dao) IsRecordValueUnique(
return query.Row(&exists) == nil && !exists
}
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
// FindAuthRecordByToken finds the auth record associated with the provided JWT.
//
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
// Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
@@ -431,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
@@ -658,26 +657,40 @@ 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.
// This is not necessary for the operation to function correctly but it helps having deterministic output during testing.
sortedRefKeys := make([]*models.Collection, 0, len(refs))
for k := range refs {
sortedRefKeys = append(sortedRefKeys, k)
}
sort.Slice(sortedRefKeys, func(i, j int) bool {
return sortedRefKeys[i].Name < sortedRefKeys[j].Name
})
for refCollection, fields := range refs {
if refCollection.IsView() {
continue // skip view collections
for _, refCollection := range sortedRefKeys {
fields, ok := refs[refCollection]
if refCollection.IsView() || !ok {
continue // skip missing or view collections
}
for _, field := range fields {
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,
+67 -13
View File
@@ -14,6 +14,8 @@ import (
)
func TestExpandRecords(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -161,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)"},
@@ -172,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)
@@ -184,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",
@@ -222,6 +248,8 @@ func TestExpandRecords(t *testing.T) {
}
func TestExpandRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -328,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)"},
@@ -339,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{
@@ -351,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 {
@@ -372,6 +424,8 @@ func TestExpandRecord(t *testing.T) {
}
func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -382,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)
@@ -407,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
+5 -1
View File
@@ -14,6 +14,8 @@ import (
)
func TestSyncRecordTableSchema(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -81,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,
},
@@ -132,6 +134,8 @@ func TestSyncRecordTableSchema(t *testing.T) {
}
func TestSingleVsMultipleValuesNormalization(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+53 -2
View File
@@ -19,6 +19,8 @@ import (
)
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -58,6 +60,8 @@ func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
}
func TestRecordQueryOneWithRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -82,6 +86,8 @@ func TestRecordQueryOneWithRecord(t *testing.T) {
}
func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -143,6 +149,8 @@ func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
}
func TestFindRecordById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -203,6 +211,8 @@ func TestFindRecordById(t *testing.T) {
}
func TestFindRecordsByIds(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -294,6 +304,8 @@ func TestFindRecordsByIds(t *testing.T) {
}
func TestFindRecordsByExpr(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -364,6 +376,8 @@ func TestFindRecordsByExpr(t *testing.T) {
}
func TestFindFirstRecordByData(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -427,6 +441,8 @@ func TestFindFirstRecordByData(t *testing.T) {
}
func TestFindRecordsByFilter(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -530,6 +546,19 @@ func TestFindRecordsByFilter(t *testing.T) {
"llvuca81nly1qls",
},
},
{
"with json filter and sort",
"demo4",
"json_object != null && json_object.a.b = 'test'",
"-json_object.a",
10,
0,
[]dbx.Params{{"active": false}},
false,
[]string{
"i9naidtvr6qsgb4",
},
},
}
for _, s := range scenarios {
@@ -566,6 +595,8 @@ func TestFindRecordsByFilter(t *testing.T) {
}
func TestFindFirstRecordByFilter(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -647,6 +678,8 @@ func TestFindFirstRecordByFilter(t *testing.T) {
}
func TestCanAccessRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -814,6 +847,8 @@ func TestCanAccessRecord(t *testing.T) {
}
func TestIsRecordValueUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -863,6 +898,8 @@ func TestIsRecordValueUnique(t *testing.T) {
}
func TestFindAuthRecordByToken(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -925,6 +962,8 @@ func TestFindAuthRecordByToken(t *testing.T) {
}
func TestFindAuthRecordByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -956,6 +995,8 @@ func TestFindAuthRecordByEmail(t *testing.T) {
}
func TestFindAuthRecordByUsername(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -988,6 +1029,8 @@ func TestFindAuthRecordByUsername(t *testing.T) {
}
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1025,6 +1068,8 @@ func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
}
func TestSaveRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1058,6 +1103,8 @@ func TestSaveRecord(t *testing.T) {
}
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1090,6 +1137,8 @@ func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
}
func TestDeleteRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1156,17 +1205,19 @@ 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)
}
}
func TestDeleteRecordBatchProcessing(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+2
View File
@@ -8,6 +8,8 @@ import (
)
func TestSaveAndFindSettings(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+12
View File
@@ -12,6 +12,8 @@ import (
)
func TestHasTable(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -36,6 +38,8 @@ func TestHasTable(t *testing.T) {
}
func TestTableColumns(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -64,6 +68,8 @@ func TestTableColumns(t *testing.T) {
}
func TestTableInfo(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestTableInfo(t *testing.T) {
}
func TestDeleteTable(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -116,6 +124,8 @@ func TestDeleteTable(t *testing.T) {
}
func TestVacuum(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -141,6 +151,8 @@ func TestVacuum(t *testing.T) {
}
func TestTableIndexes(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+10
View File
@@ -33,6 +33,8 @@ func ensureNoTempViews(app core.App, t *testing.T) {
}
func TestDeleteView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -60,6 +62,8 @@ func TestDeleteView(t *testing.T) {
}
func TestSaveView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -180,6 +184,8 @@ func TestSaveView(t *testing.T) {
}
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -196,6 +202,8 @@ func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
}
func TestCreateViewSchema(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -486,6 +494,8 @@ func TestCreateViewSchema(t *testing.T) {
}
func TestFindRecordByViewFile(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+4
View File
@@ -10,6 +10,8 @@ import (
)
func TestAdminLoginValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -51,6 +53,8 @@ func TestAdminLoginValidateAndSubmit(t *testing.T) {
}
func TestAdminLoginInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -11,6 +11,8 @@ import (
)
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -99,6 +101,8 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
}
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -10,6 +10,8 @@ import (
)
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -74,6 +76,8 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
}
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+8
View File
@@ -12,6 +12,8 @@ import (
)
func TestNewAdminUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -31,6 +33,8 @@ func TestNewAdminUpsert(t *testing.T) {
}
func TestAdminUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -183,6 +187,8 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
}
func TestAdminUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -231,6 +237,8 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
}
func TestAdminUpsertWithCustomId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+2 -2
View File
@@ -12,7 +12,7 @@ import (
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
// AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
//
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct {
@@ -33,7 +33,7 @@ type AppleClientSecretCreate struct {
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"`
// Duration specifies how long the generated JWT token should be considered valid.
// Duration specifies how long the generated JWT should be considered valid.
// The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"`
}
+2
View File
@@ -15,6 +15,8 @@ import (
)
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+2
View File
@@ -10,6 +10,8 @@ import (
)
func TestBackupCreateValidateAndSubmit(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
backupName string
+2
View File
@@ -12,6 +12,8 @@ import (
)
func TestBackupUploadValidateAndSubmit(t *testing.T) {
t.Parallel()
var zb bytes.Buffer
zw := zip.NewWriter(&zb)
if err := zw.Close(); err != nil {
+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)
+19
View File
@@ -16,6 +16,8 @@ import (
)
func TestNewCollectionUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -88,6 +90,8 @@ func TestNewCollectionUpsert(t *testing.T) {
}
func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -101,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",
"",
@@ -654,6 +669,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}
func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -704,6 +721,8 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
}
func TestCollectionUpsertWithCustomId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+6
View File
@@ -11,6 +11,8 @@ import (
)
func TestCollectionsImportValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -38,6 +40,8 @@ func TestCollectionsImportValidate(t *testing.T) {
}
func TestCollectionsImportSubmit(t *testing.T) {
t.Parallel()
totalCollections := 11
scenarios := []struct {
@@ -461,6 +465,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
}
func TestCollectionsImportSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+4
View File
@@ -19,5 +19,9 @@ func NewRealtimeSubscribe() *RealtimeSubscribe {
func (form *RealtimeSubscribe) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)),
validation.Field(&form.Subscriptions,
validation.Length(0, 1000),
validation.Each(validation.Length(0, 2500)),
),
)
}
+80 -15
View File
@@ -1,31 +1,96 @@
package forms_test
import (
"encoding/json"
"fmt"
"strings"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
)
func TestRealtimeSubscribeValidate(t *testing.T) {
scenarios := []struct {
clientId string
expectError bool
}{
{"", true},
{strings.Repeat("a", 256), true},
{"test", false},
t.Parallel()
validSubscriptionsLimit := make([]string, 1000)
for i := 0; i < len(validSubscriptionsLimit); i++ {
validSubscriptionsLimit[i] = fmt.Sprintf(`"%d"`, i)
}
invalidSubscriptionsLimit := make([]string, 1001)
for i := 0; i < len(invalidSubscriptionsLimit); i++ {
invalidSubscriptionsLimit[i] = fmt.Sprintf(`"%d"`, i)
}
for i, s := range scenarios {
form := forms.NewRealtimeSubscribe()
form.ClientId = s.clientId
scenarios := []struct {
name string
data string
expectedErrors []string
}{
{
"empty data",
`{}`,
[]string{"clientId"},
},
{
"clientId > max chars limit",
`{"clientId":"` + strings.Repeat("a", 256) + `"}`,
[]string{"clientId"},
},
{
"clientId <= max chars limit",
`{"clientId":"` + strings.Repeat("a", 255) + `"}`,
[]string{},
},
{
"total subscriptions > max limit",
`{"clientId":"test", "subscriptions":[` + strings.Join(invalidSubscriptionsLimit, ",") + `]}`,
[]string{"subscriptions"},
},
{
"total subscriptions <= max limit",
`{"clientId":"test", "subscriptions":[` + strings.Join(validSubscriptionsLimit, ",") + `]}`,
[]string{},
},
{
"single subscription > max limit",
`{"clientId":"test", "subscriptions":["abc", "` + strings.Repeat("a", 2501) + `"]}`,
[]string{"subscriptions"},
},
{
"single subscription <= max limit",
`{"clientId":"test", "subscriptions":["abc", "` + strings.Repeat("a", 2500) + `"]}`,
[]string{},
},
}
err := form.Validate()
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
form := forms.NewRealtimeSubscribe()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
err := json.Unmarshal([]byte(s.data), &form)
if err != nil {
t.Fatal(err)
}
result := form.Validate()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
return
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
})
}
}
@@ -13,6 +13,8 @@ import (
)
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -145,6 +147,8 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
}
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -12,6 +12,8 @@ import (
)
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -106,6 +108,8 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
}
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+22 -9
View File
@@ -129,6 +129,10 @@ func (form *RecordOAuth2Login) Submit(
return nil, nil, err
}
if form.Provider == auth.NameInstagram {
form.app.Logger().Warn("Instagram OAuth2 provider is deprecated and will stop working after December 4th. For more details please check https://github.com/pocketbase/pocketbase/discussions/5652.")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -222,15 +226,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 +247,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)
+2
View File
@@ -10,6 +10,8 @@ import (
)
func TestUserOauth2LoginValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+4
View File
@@ -10,6 +10,8 @@ import (
)
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -132,6 +134,8 @@ func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
}
func TestRecordPasswordLoginInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+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 {
@@ -13,6 +13,8 @@ import (
)
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -136,6 +138,8 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
}
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+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(
@@ -13,6 +13,8 @@ import (
)
func TestRecordPasswordResetRequestSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -116,6 +118,8 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
}
func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+50 -22
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 {
@@ -746,6 +756,8 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
dao := form.dao.Clone()
var uploaded []string
// upload new files (if any)
//
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions
@@ -755,7 +767,9 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
if err := form.processFilesToUpload(); err != nil {
var err error
uploaded, err = form.processFilesToUpload()
if err != nil {
return err
}
}
@@ -773,7 +787,9 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
if err := form.processFilesToUpload(); err != nil {
var err error
uploaded, err = form.processFilesToUpload()
if err != nil {
return err
}
}
@@ -791,6 +807,9 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
// persist the record model
if err := dao.SaveRecord(form.record); err != nil {
// cleanup - try to delete only the successfully uploaded files
form.deleteFilesByNamesList(uploaded)
return form.prepareError(err)
}
@@ -809,30 +828,30 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
}, interceptors...)
}
func (form *RecordUpsert) processFilesToUpload() error {
func (form *RecordUpsert) processFilesToUpload() ([]string, error) {
if len(form.filesToUpload) == 0 {
return nil // no parsed file fields
return nil, nil // no parsed file fields
}
if !form.record.HasId() {
return errors.New("the record doesn't have an id")
return nil, errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
fsys, err := form.app.NewFilesystem()
if err != nil {
return err
return nil, err
}
defer fs.Close()
defer fsys.Close()
var uploadErrors []error // list of upload errors
var uploaded []string // list of uploaded file paths
var uploaded []string // list of uploaded file names
for fieldKey := range form.filesToUpload {
for i, file := range form.filesToUpload[fieldKey] {
path := form.record.BaseFilesPath() + "/" + file.Name
if err := fs.UploadFile(file, path); err == nil {
if err := fsys.UploadFile(file, path); err == nil {
// keep track of the already uploaded file
uploaded = append(uploaded, path)
uploaded = append(uploaded, file.Name)
} else {
// store the upload error
uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err))
@@ -844,10 +863,10 @@ func (form *RecordUpsert) processFilesToUpload() error {
// cleanup - try to delete the successfully uploaded files (if any)
form.deleteFilesByNamesList(uploaded)
return fmt.Errorf("failed to upload all files: %v", uploadErrors)
return nil, fmt.Errorf("failed to upload all files: %v", uploadErrors)
}
return nil
return uploaded, nil
}
func (form *RecordUpsert) processFilesToDelete() (err error) {
@@ -866,24 +885,28 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
return filenames, errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
isNew := form.record.IsNew()
baseDir := form.record.BaseFilesPath()
fsys, err := form.app.NewFilesystem()
if err != nil {
return filenames, err
}
defer fs.Close()
defer fsys.Close()
var deleteErrors []error
for i := len(filenames) - 1; i >= 0; i-- {
filename := filenames[i]
path := form.record.BaseFilesPath() + "/" + filename
path := baseDir + "/" + filename
if err := fs.Delete(path); err == nil {
if err := fsys.Delete(path); err == nil {
// remove the deleted file from the list
filenames = append(filenames[:i], filenames[i+1:]...)
// try to delete the related file thumbs (if any)
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
fsys.DeletePrefix(baseDir + "/thumbs_" + filename + "/")
} else {
// store the delete error
deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err))
@@ -894,6 +917,11 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
return filenames, fmt.Errorf("failed to delete all files: %v", deleteErrors)
}
// cleanup empty dir for new records if there are no other files
if isNew && fsys.IsEmptyDir(baseDir) {
_ = fsys.Delete(baseDir)
}
return filenames, nil
}
+106 -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")
@@ -604,6 +619,94 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
}
}
func TestRecordUpsertSubmitFailureFilesCleanup(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// ensure that there is a unique index constraint
collection.Indexes = append(collection.Indexes, `CREATE UNIQUE INDEX "idx_unique_demo3_title" on "demo3" ("title")`)
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
f1, err := filesystem.NewFileFromBytes([]byte("test1"), "new_test1")
if err != nil {
t.Fatal(err)
}
f2, err := filesystem.NewFileFromBytes([]byte("test1"), "new_test2")
if err != nil {
t.Fatal(err)
}
uploadedFiles := []string{f1.Name, f2.Name}
existing, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
recordId string
}{
{""},
{"7nwo8tuiatetxdm"},
{"1tmknxy2868d869"},
}
for _, s := range scenarios {
sname := s.recordId
if sname == "" {
sname = "new"
}
t.Run(sname, func(t *testing.T) {
var originalFiles []string
var record *models.Record
if s.recordId == "" {
record = models.NewRecord(collection)
} else {
record, err = app.Dao().FindRecordById(collection.Id, s.recordId)
if err != nil {
t.Fatal(err)
}
originalFiles = record.GetStringSlice("files")
}
record.Set("title", existing.GetString("title"))
form := forms.NewRecordUpsert(app, record)
form.AddFiles("files", f1, f2)
err := form.Submit()
if err == nil {
t.Fatal("Expected form submit to fail")
}
// ensure that the original files still exists
for _, name := range originalFiles {
if !hasRecordFile(app, record, name) {
t.Fatalf("Missing original file %q", name)
}
}
// ensure that the new uploaded files were deleted
for _, name := range uploadedFiles {
if hasRecordFile(app, record, name) {
t.Fatalf("Expected file %q to be deleted", name)
}
}
})
}
}
func TestRecordUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -12,6 +12,8 @@ import (
)
func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -98,6 +100,8 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
}
func TestRecordVerificationConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -13,6 +13,8 @@ import (
)
func TestRecordVerificationRequestSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -134,6 +136,8 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
}
func TestRecordVerificationRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+6
View File
@@ -14,6 +14,8 @@ import (
)
func TestNewSettingsUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -30,6 +32,8 @@ func TestNewSettingsUpsert(t *testing.T) {
}
func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -127,6 +131,8 @@ func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
}
func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+2
View File
@@ -10,6 +10,8 @@ import (
)
func TestEmailSendValidateAndSubmit(t *testing.T) {
t.Parallel()
scenarios := []struct {
template string
email string
+4
View File
@@ -9,6 +9,8 @@ import (
)
func TestS3FilesystemValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -66,6 +68,8 @@ func TestS3FilesystemValidate(t *testing.T) {
}
func TestS3FilesystemSubmitFailure(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+4
View File
@@ -12,6 +12,8 @@ import (
)
func TestUploadedFileSize(t *testing.T) {
t.Parallel()
data, mp, err := tests.MockMultipartData(nil, "test")
if err != nil {
t.Fatal(err)
@@ -52,6 +54,8 @@ func TestUploadedFileSize(t *testing.T) {
}
func TestUploadedFileMimeType(t *testing.T) {
t.Parallel()
data, mp, err := tests.MockMultipartData(nil, "test")
if err != nil {
t.Fatal(err)
+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:
//
+2
View File
@@ -8,6 +8,8 @@ import (
)
func TestUniqueId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
+5
View File
@@ -2,6 +2,7 @@ package validators
import (
"fmt"
"math"
"net/url"
"regexp"
"strings"
@@ -159,6 +160,10 @@ func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField
return nil // nothing to check (skip zero-defaults)
}
if math.IsInf(val, 0) || math.IsNaN(val) {
return validation.NewError("validation_nan", "The submitted number is not properly formatted")
}
options, _ := field.Options.(*schema.NumberOptions)
if options.NoDecimal && val != float64(int64(val)) {
+12
View File
@@ -45,6 +45,8 @@ func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) {
}
func TestRecordDataValidatorValidateText(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -246,6 +248,16 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(number) check infinities and NaN",
map[string]any{
"field1": "Inf",
"field2": "-Inf",
"field4": "NaN",
},
nil,
[]string{"field1", "field2", "field4"},
},
{
"(number) check min constraint",
map[string]any{
+2
View File
@@ -7,6 +7,8 @@ import (
)
func TestCompare(t *testing.T) {
t.Parallel()
scenarios := []struct {
valA string
valB string
+58 -64
View File
@@ -1,96 +1,90 @@
module github.com/pocketbase/pocketbase
go 1.21
go 1.22.0
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go v1.48.16
github.com/aws/aws-sdk-go-v2 v1.32.2
github.com/aws/aws-sdk-go-v2/config v1.27.31
github.com/aws/aws-sdk-go-v2/credentials v1.17.30
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.1
github.com/aws/smithy-go v1.22.0
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-20241009100908-5f46f2705ca3
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
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.5
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.18
github.com/mattn/go-sqlite3 v1.14.22
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4
github.com/spf13/cast v1.6.0
github.com/spf13/cobra v1.8.0
gocloud.dev v0.35.0
golang.org/x/crypto v0.16.0
golang.org/x/net v0.19.0
golang.org/x/oauth2 v0.15.0
golang.org/x/sync v0.5.0
modernc.org/sqlite v1.27.0
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75
github.com/spf13/cast v1.7.0
github.com/spf13/cobra v1.8.1
gocloud.dev v0.39.0
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
golang.org/x/oauth2 v0.22.0
golang.org/x/sync v0.8.0
modernc.org/sqlite v1.32.0
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.24.0 // 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.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // 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.9 // 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.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 // indirect
github.com/dlclark/regexp2 v1.11.4 // 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.4.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-20240827171923-fa2c70bbbfe5 // 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.14.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.16.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/api v0.153.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.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.37.0 // indirect
golang.org/x/image v0.19.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.26.0 // indirect
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
google.golang.org/api v0.194.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // 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
)
+171 -188
View File
@@ -1,70 +1,72 @@
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.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
cloud.google.com/go/auth v0.9.1 h1:+pMtLEV2k0AXKvs/tGZojuj6QaioxfUjOpMsG5Gtx+w=
cloud.google.com/go/auth v0.9.1/go.mod h1:Sw8ocT5mhhXxFklyhT12Eiy0ed6tTrPMCJjSI8KhYLk=
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
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.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI=
github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4/go.mod h1:/MQxMqci8tlqDH+pjmoLu1i0tbWCUP1hhyMRuFxpQCw=
github.com/aws/aws-sdk-go-v2/config v1.27.31 h1:kxBoRsjhT3pq0cKthgj6RU6bXTm/2SgdoUMyrVw0rAI=
github.com/aws/aws-sdk-go-v2/config v1.27.31/go.mod h1:z04nZdSWFPaDwK3DdJOG2r+scLQzMYuJeW0CujEm9FM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.30 h1:aau/oYFtibVovr2rDt8FHlU17BTicFEMAi29V1U+L5Q=
github.com/aws/aws-sdk-go-v2/credentials v1.17.30/go.mod h1:BPJ/yXV92ZVq6G8uYvbU0gSl8q94UB63nMT5ctNO38g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.15 h1:ijB7hr56MngOiELJe0C5aQRaBQ11LveNgWFyG02AUto=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.15/go.mod h1:0QEmQSSWMVfiAk93l1/ayR9DQ9+jwni7gHS2NARZXB0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.1 h1:mx2ucgtv+MWzJesJY9Ig/8AFHgoE5FwLXwUVgW/FGdI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.1/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 h1:OMsEmCyz2i89XwRwPouAJvhj81wINh+4UK+k/0Yo/q8=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.5/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0=
github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
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=
@@ -72,44 +74,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.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/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-20241009100908-5f46f2705ca3 h1:MXsAuToxwsTn5BEEYm2DheqIiC4jWGmkEJ1uy+KFhvQ=
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU=
github.com/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.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
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.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/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=
@@ -127,58 +130,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/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/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/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/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=
@@ -190,28 +176,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.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-20241015175937-d6ff411a0f75 h1:XSbmekxgmbI2uPrre/nkCz7y8VsV652TPb3hAYzPb74=
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.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/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -231,23 +218,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=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
gocloud.dev v0.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds=
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
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.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
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.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -258,17 +255,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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
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.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.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=
@@ -276,65 +273,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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.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.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
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/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/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.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.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
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=
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.194.0 h1:dztZKG9HgtIpbI35FhfuSNR/zmaMVdxNlntHj1sIS4s=
google.golang.org/api v0.194.0/go.mod h1:AgvUFdojGANh3vI+P7EVnxj3AISHllxGCJSFmggmnd0=
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/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 v0.0.0-20240814211410-ddb44dafa142 h1:oLiyxGgE+rt22duwci1+TG7bg2/L1LQsXwfjPlmuJA0=
google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142/go.mod h1:G11eXq53iI5Q+kyNOmCvnzBaxEA2Q/Ik5Tj7nqBE8j4=
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 h1:+/tmTy5zAieooKIXfzDm9KiA3Bv6JBwriRN9LY+yayk=
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed h1:J6izYgfBXAI3xTKLgxzTmUltdYaLsuBxFCgDHWJ/eXg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
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.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=
@@ -344,16 +335,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.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=
@@ -361,31 +346,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/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-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/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/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
modernc.org/sqlite v1.32.0/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=
+2
View File
@@ -9,6 +9,8 @@ import (
)
func TestSendAdminPasswordReset(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+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 {
+35
View File
@@ -8,7 +8,38 @@ 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()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -37,6 +68,8 @@ func TestSendRecordPasswordReset(t *testing.T) {
}
func TestSendRecordVerification(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -62,6 +95,8 @@ func TestSendRecordVerification(t *testing.T) {
}
func TestSendRecordChangeEmail(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+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}}
`
@@ -28,7 +28,7 @@ func init() {
}
options, _ := f.Options.(*schema.JsonOptions)
if options != nil {
if options == nil {
options = &schema.JsonOptions{}
}
options.MaxSize = 2000000 // 2mb
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff. Show more