Compare commits

...
291 Commits
Author SHA1 Message Date
Gani Georgiev 84c2c558da bumped min go github action version to 1.26.5 2026-07-08 07:03:31 +03:00
Gani Georgiev ac04d7d66e bumped min go github action version to 1.26.4 2026-06-03 15:22:43 +03:00
Gani Georgiev 889c5654e3 updated golang.org/x/ deps 2026-05-22 07:40:46 +03:00
Gani Georgiev 884045d2fe backported realtime auth unset on record tokenKey change 2026-05-14 06:21:17 +03:00
Gani Georgiev aea5de233b bumped min go github action to 1.26.3 2026-05-07 20:02:50 +03:00
Gani Georgiev bcd2ac6f1b adjusted test for the older cast version 2026-04-27 09:42:33 +03:00
Gani Georgiev 577d20bf5d updated ui/dist 2026-04-27 08:29:08 +03:00
Gani Georgiev b2de05e0a4 backport sync with v0.37.4 2026-04-27 08:02:07 +03:00
Gani Georgiev d239adcc0b (backported from v0.37.4) bitbucket,github,gitea providers update 2026-04-26 15:25:58 +03:00
Gani Georgiev 056a0a99bf (backported from v0.37.4) deleted all previous external auths on verified upgrade 2026-04-26 15:20:02 +03:00
Gani Georgiev 2a387ffc62 bumped modernc.org/sqlite to 1.48.2 2026-04-09 17:37:19 +03:00
Gani Georgiev 755502afcd backported v0.36.9 fixes 2026-04-07 08:33:40 +03:00
Gani Georgiev 21787ba211 backported v0.36.7 fixes 2026-03-16 19:11:21 +02:00
Gani Georgiev d1024accc1 removed old test 2026-03-06 09:45:28 +02:00
Gani Georgiev adc3b0ab1c bumped min Go GitHub action to 1.26.1 2026-03-06 09:34:53 +02:00
Gani Georgiev 297d085983 bumped min Go GitHub action to 1.25.6 2026-01-15 23:16:42 +02:00
Gani Georgiev fb120daaa5 updated changelog 2025-12-02 20:09:33 +02:00
Gani Georgiev 529cb8cd6b use go 1.25.5 2025-12-02 20:01:18 +02:00
Gani Georgiev 2e4c9db111 bumped actions/setup-go to v6 2025-12-02 20:00:34 +02:00
Gani Georgiev 81ab302068 try again with check-latest:true 2025-12-02 19:51:45 +02:00
Gani Georgiev 86cc345738 bumped min go github action go 1.25.5 2025-12-02 19:47:20 +02:00
Gani Georgiev 37601fe2de bumped min go action version and added missing : char to the autocomplete regex 2025-12-02 19:37:39 +02:00
Gani Georgiev 9014604506 bumped min go action version 2025-10-08 18:06:49 +03:00
Gani Georgiev cbc7846b96 updated changelog 2025-08-09 09:55:15 +03:00
Gani Georgiev 4c5d13fb4f bumped min go github action version 2025-08-09 09:53:50 +03:00
Gani Georgiev ea7320746f added missing cast import 2025-03-29 09:37:37 +02:00
Gani Georgiev 2ca9b63138 [#6657] allow OIDC email_verified to be int or boolean string 2025-03-29 09:35:43 +02:00
Gani Georgiev 3f14a6eab7 fixed and normalized logs error serialization 2025-03-23 21:17:51 +02:00
Gani Georgiev f99903792a updated golang-jwt 2025-03-22 06:00:17 +02:00
Gani Georgiev 7ec7ccc6f0 set the current working directory as a default goja script path when executing inline JS strings 2025-02-18 18:28:52 +02:00
Gani Georgiev 18dcc133f0 updated ui/dist and min GitHub Go release action version 2025-01-18 12:35:53 +02:00
Gani Georgiev ae36612c34 (backported) fixed fields extraction for view queries with milti-level comments 2025-01-18 12:23:41 +02:00
Gani Georgiev fbb8fca48e bumped golang.org/x/net to 0.33.0 2024-12-19 09:52:21 +02:00
Gani Georgiev 0c966ab454 (backported) upgraded default modernc.org driver to fix arm64 disk io error 2024-12-18 18:34:44 +02:00
Gani Georgiev eb78f59d31 (backported) renew the superuser file token cache when clicking on the thumb preview or download link 2024-12-18 15:48:14 +02:00
Gani Georgiev b83f924c7d updated changelog 2024-11-19 22:12:12 +02:00
Gani Georgiev 51c083dd58 [#5898] instead of unregister, unset the realtime client auth state on deleting the related auth record 2024-11-19 22:09:34 +02:00
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
404 changed files with 17065 additions and 10148 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 runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.8.1 node-version: 20.11.0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v6
with: with:
go-version: '>=1.21.3' go-version: '>=1.26.5'
# This step usually is not needed because the /ui/dist is pregenerated locally # 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. # 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] builds: [build_noncgo]
format: zip format: zip
files: files:
- LICENSE* - LICENSE.md
- CHANGELOG* - CHANGELOG.md
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'
+563 -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 ## Prerequisites
- Go 1.19+ (for making changes in the Go code) - Go 1.25+ (for making changes in the Go code)
- Node 16+ (for making changes in the Admin UI) - 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: 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"> <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/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://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> </p>
[PocketBase](https://pocketbase.io) is an open source Go backend, consisting of: [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 ## 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. your own custom app specific business logic and still have a single portable executable at the end.
### Installation Here is a minimal example:
```sh 0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
# go 1.21+
go get github.com/pocketbase/pocketbase
```
### Example 1. Create a new project directory with the following `main.go` file inside it:
```go
package main
```go import (
package main "log"
"net/http"
import ( "github.com/labstack/echo/v5"
"log" "github.com/pocketbase/pocketbase"
"net/http" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
"github.com/labstack/echo/v5" func main() {
"github.com/pocketbase/pocketbase" app := pocketbase.New()
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() { app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
app := pocketbase.New() // 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 { return nil
// 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 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). > [!NOTE]
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation. > 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: 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_) (_https://go.dev/doc/install/source#environment_)
4. Start the created executable by running `./base serve`. 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 darwin amd64
@@ -121,7 +133,7 @@ windows arm64
### Testing ### Testing
PocketBase comes with mixed bag of unit and integration tests. 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 ```sh
go test ./... go test ./...
@@ -144,7 +156,6 @@ You could help continuing its development by:
- [Contribute to the source code](CONTRIBUTING.md) - [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues) - [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. 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. 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). 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) { func TestAdminAuthWithPassword(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -119,6 +121,8 @@ func TestAdminAuthWithPassword(t *testing.T) {
} }
func TestAdminRequestPasswordReset(t *testing.T) { func TestAdminRequestPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -188,6 +192,8 @@ func TestAdminRequestPasswordReset(t *testing.T) {
} }
func TestAdminConfirmPasswordReset(t *testing.T) { func TestAdminConfirmPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -277,6 +283,8 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
} }
func TestAdminRefresh(t *testing.T) { func TestAdminRefresh(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -350,6 +358,8 @@ func TestAdminRefresh(t *testing.T) {
} }
func TestAdminsList(t *testing.T) { func TestAdminsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -453,6 +463,8 @@ func TestAdminsList(t *testing.T) {
} }
func TestAdminView(t *testing.T) { func TestAdminView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -508,6 +520,8 @@ func TestAdminView(t *testing.T) {
} }
func TestAdminDelete(t *testing.T) { func TestAdminDelete(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -603,6 +617,8 @@ func TestAdminDelete(t *testing.T) {
} }
func TestAdminCreate(t *testing.T) { func TestAdminCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized (while having at least 1 existing admin)", Name: "unauthorized (while having at least 1 existing admin)",
@@ -757,6 +773,8 @@ func TestAdminCreate(t *testing.T) {
} }
func TestAdminUpdate(t *testing.T) { func TestAdminUpdate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
+12
View File
@@ -10,6 +10,8 @@ import (
) )
func TestNewApiErrorWithRawData(t *testing.T) { func TestNewApiErrorWithRawData(t *testing.T) {
t.Parallel()
e := apis.NewApiError( e := apis.NewApiError(
300, 300,
"message_test", "message_test",
@@ -33,6 +35,8 @@ func TestNewApiErrorWithRawData(t *testing.T) {
} }
func TestNewApiErrorWithValidationData(t *testing.T) { func TestNewApiErrorWithValidationData(t *testing.T) {
t.Parallel()
e := apis.NewApiError( e := apis.NewApiError(
300, 300,
"message_test", "message_test",
@@ -66,6 +70,8 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
} }
func TestNewNotFoundError(t *testing.T) { func TestNewNotFoundError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
@@ -87,6 +93,8 @@ func TestNewNotFoundError(t *testing.T) {
} }
func TestNewBadRequestError(t *testing.T) { func TestNewBadRequestError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
@@ -108,6 +116,8 @@ func TestNewBadRequestError(t *testing.T) {
} }
func TestNewForbiddenError(t *testing.T) { func TestNewForbiddenError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
@@ -129,6 +139,8 @@ func TestNewForbiddenError(t *testing.T) {
} }
func TestNewUnauthorizedError(t *testing.T) { func TestNewUnauthorizedError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
+12
View File
@@ -17,6 +17,8 @@ import (
) )
func TestBackupsList(t *testing.T) { func TestBackupsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -84,6 +86,8 @@ func TestBackupsList(t *testing.T) {
} }
func TestBackupsCreate(t *testing.T) { func TestBackupsCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -198,6 +202,8 @@ func TestBackupsCreate(t *testing.T) {
} }
func TestBackupsUpload(t *testing.T) { func TestBackupsUpload(t *testing.T) {
t.Parallel()
// create dummy form data bodies // create dummy form data bodies
type body struct { type body struct {
buffer io.Reader buffer io.Reader
@@ -330,6 +336,8 @@ func TestBackupsUpload(t *testing.T) {
} }
func TestBackupsDownload(t *testing.T) { func TestBackupsDownload(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -485,6 +493,8 @@ func TestBackupsDownload(t *testing.T) {
} }
func TestBackupsDelete(t *testing.T) { func TestBackupsDelete(t *testing.T) {
t.Parallel()
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) { noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
files, err := getBackupFiles(app) files, err := getBackupFiles(app)
if err != nil { if err != nil {
@@ -645,6 +655,8 @@ func TestBackupsDelete(t *testing.T) {
} }
func TestBackupsRestore(t *testing.T) { func TestBackupsRestore(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
+3 -2
View File
@@ -28,6 +28,7 @@ const trailedAdminPath = "/_/"
func InitApi(app core.App) (*echo.Echo, error) { func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New() e := echo.New()
e.Debug = false e.Debug = false
e.Binder = &rest.MultiBinder{}
e.JSONSerializer = &rest.Serializer{ e.JSONSerializer = &rest.Serializer{
FieldsParam: fieldsQueryParam, FieldsParam: fieldsQueryParam,
} }
@@ -82,7 +83,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
logRequest(app, c, apiErr) logRequest(app, c, apiErr)
if c.Response().Committed { if c.Response().Committed {
return // already commited return // already committed
} }
event := new(core.ApiErrorEvent) event := new(core.ApiErrorEvent)
@@ -105,7 +106,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
if hookErr == nil { if hookErr == nil {
if err := app.OnAfterApiError().Trigger(event); err != 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 { } else {
app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error())) 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/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
func Test404(t *testing.T) { func Test404(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Method: http.MethodGet, Method: http.MethodGet,
@@ -53,6 +56,8 @@ func Test404(t *testing.T) {
} }
func TestCustomRoutesAndErrorsHandling(t *testing.T) { func TestCustomRoutesAndErrorsHandling(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "custom route", Name: "custom route",
@@ -142,6 +147,8 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
} }
func TestRemoveTrailingSlashMiddleware(t *testing.T) { func TestRemoveTrailingSlashMiddleware(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "non /api/* route (exact match)", 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{ scenarios := []tests.ApiScenario{
{ {
Name: "custom non-api group route", Name: "non-api group route",
Method: "POST", Method: "POST",
Url: "/custom", Url: "/custom",
Body: strings.NewReader(`{"name":"test123"}`), Body: strings.NewReader(rawJson),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
Method: "POST", Method: "POST",
@@ -234,11 +252,10 @@ func TestEagerRequestInfoCache(t *testing.T) {
return err return err
} }
// since the unknown method is not eager cache support // try to read the body again
// it should fail reading the json body twice
r := apis.RequestInfo(c) r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "" { if v := cast.ToString(r.Data["name"]); v != "test123" {
t.Fatalf("Expected empty request data body, got, %v", r.Data) t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
} }
return c.NoContent(200) return c.NoContent(200)
@@ -248,41 +265,10 @@ func TestEagerRequestInfoCache(t *testing.T) {
ExpectedStatus: 200, ExpectedStatus: 200,
}, },
{ {
Name: "api group route with unsupported eager cache request method", Name: "api group route",
Method: "GET", Method: "GET",
Url: "/api/admins", 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 {
// 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"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@@ -308,6 +294,39 @@ func TestEagerRequestInfoCache(t *testing.T) {
}, },
ExpectedStatus: 200, 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 { for _, scenario := range scenarios {
@@ -316,6 +335,8 @@ func TestEagerRequestInfoCache(t *testing.T) {
} }
func TestErrorHandler(t *testing.T) { func TestErrorHandler(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "apis.ApiError", Name: "apis.ApiError",
+14 -1
View File
@@ -17,6 +17,8 @@ import (
) )
func TestCollectionsList(t *testing.T) { func TestCollectionsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -125,6 +127,8 @@ func TestCollectionsList(t *testing.T) {
} }
func TestCollectionView(t *testing.T) { func TestCollectionView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -193,6 +197,8 @@ func TestCollectionView(t *testing.T) {
} }
func TestCollectionDelete(t *testing.T) { func TestCollectionDelete(t *testing.T) {
t.Parallel()
ensureDeletedFiles := func(app *tests.TestApp, collectionId string) { ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId) storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
@@ -338,6 +344,8 @@ func TestCollectionDelete(t *testing.T) {
} }
func TestCollectionCreate(t *testing.T) { func TestCollectionCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -715,6 +723,8 @@ func TestCollectionCreate(t *testing.T) {
} }
func TestCollectionUpdate(t *testing.T) { func TestCollectionUpdate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -883,7 +893,8 @@ func TestCollectionUpdate(t *testing.T) {
{"type":"text","name":"password"}, {"type":"text","name":"password"},
{"type":"text","name":"passwordConfirm"}, {"type":"text","name":"passwordConfirm"},
{"type":"text","name":"oldPassword"} {"type":"text","name":"oldPassword"}
] ],
"indexes": []
}`), }`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
@@ -1082,6 +1093,8 @@ func TestCollectionUpdate(t *testing.T) {
} }
func TestCollectionsImport(t *testing.T) { func TestCollectionsImport(t *testing.T) {
t.Parallel()
totalCollections := 11 totalCollections := 11
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
+3 -2
View File
@@ -30,7 +30,7 @@ var defaultThumbSizes = []string{"100x100"}
func bindFileApi(app core.App, rg *echo.Group) { func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{ api := fileApi{
app: app, 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), thumbGenPending: new(singleflight.Group),
thumbGenMaxWait: 60 * time.Second, thumbGenMaxWait: 60 * time.Second,
} }
@@ -111,7 +111,7 @@ func (api *fileApi) download(c echo.Context) error {
options, ok := fileField.Options.(*schema.FileOptions) options, ok := fileField.Options.(*schema.FileOptions)
if !ok { 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 // 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 // create a copy of the cached request data and adjust it for the current auth model
requestInfo := *RequestInfo(c) requestInfo := *RequestInfo(c)
requestInfo.Context = models.RequestInfoContextProtectedFile
requestInfo.Admin = nil requestInfo.Admin = nil
requestInfo.AuthRecord = nil requestInfo.AuthRecord = nil
if adminOrAuthRecord != nil { if adminOrAuthRecord != nil {
+6
View File
@@ -20,6 +20,8 @@ import (
) )
func TestFileToken(t *testing.T) { func TestFileToken(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -92,6 +94,8 @@ func TestFileToken(t *testing.T) {
} }
func TestFileDownload(t *testing.T) { func TestFileDownload(t *testing.T) {
t.Parallel()
_, currentFile, _, _ := runtime.Caller(0) _, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/" dataDirRelPath := "../tests/data/"
@@ -391,6 +395,8 @@ func TestFileDownload(t *testing.T) {
} }
func TestConcurrentThumbsGeneration(t *testing.T) { func TestConcurrentThumbsGeneration(t *testing.T) {
t.Parallel()
app, err := tests.NewTestApp() app, err := tests.NewTestApp()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+6 -1
View File
@@ -12,6 +12,7 @@ func bindHealthApi(app core.App, rg *echo.Group) {
api := healthApi{app: app} api := healthApi{app: app}
subGroup := rg.Group("/health") subGroup := rg.Group("/health")
subGroup.HEAD("", api.healthCheck)
subGroup.GET("", api.healthCheck) subGroup.GET("", api.healthCheck)
} }
@@ -20,8 +21,8 @@ type healthApi struct {
} }
type healthCheckResponse struct { type healthCheckResponse struct {
Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Code int `json:"code"`
Data struct { Data struct {
CanBackup bool `json:"canBackup"` CanBackup bool `json:"canBackup"`
} `json:"data"` } `json:"data"`
@@ -29,6 +30,10 @@ type healthCheckResponse struct {
// healthCheck returns a 200 OK response if the server is healthy. // healthCheck returns a 200 OK response if the server is healthy.
func (api *healthApi) healthCheck(c echo.Context) error { func (api *healthApi) healthCheck(c echo.Context) error {
if c.Request().Method == http.MethodHead {
return c.NoContent(http.StatusOK)
}
resp := new(healthCheckResponse) resp := new(healthCheckResponse)
resp.Code = http.StatusOK resp.Code = http.StatusOK
resp.Message = "API is healthy." resp.Message = "API is healthy."
+9 -1
View File
@@ -8,9 +8,17 @@ import (
) )
func TestHealthAPI(t *testing.T) { func TestHealthAPI(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ 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, Method: http.MethodGet,
Url: "/api/health", Url: "/api/health",
ExpectedStatus: 200, ExpectedStatus: 200,
+6
View File
@@ -9,6 +9,8 @@ import (
) )
func TestLogsList(t *testing.T) { func TestLogsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -78,6 +80,8 @@ func TestLogsList(t *testing.T) {
} }
func TestLogView(t *testing.T) { func TestLogView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -136,6 +140,8 @@ func TestLogView(t *testing.T) {
} }
func TestLogsStats(t *testing.T) { func TestLogsStats(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
+14 -4
View File
@@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -260,7 +261,7 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
if param := c.PathParam("collection"); param != "" { if param := c.PathParam("collection"); param != "" {
collection, err := app.Dao().FindCollectionByNameOrId(param) collection, err := core.FindCachedCollectionByNameOrId(app, param)
if err != nil || collection == nil { if err != nil || collection == nil {
return NewNotFoundError("", err) return NewNotFoundError("", err)
} }
@@ -315,7 +316,7 @@ func logRequest(app core.App, c echo.Context, err *ApiError) {
httpResponse := c.Response() httpResponse := c.Response()
method := strings.ToUpper(httpRequest.Method) method := strings.ToUpper(httpRequest.Method)
status := httpResponse.Status status := httpResponse.Status
url := httpRequest.URL.RequestURI() requestUri := httpRequest.URL.RequestURI()
// parse the request error // parse the request error
if err != nil { if err != nil {
@@ -336,7 +337,7 @@ func logRequest(app core.App, c echo.Context, err *ApiError) {
attrs = append( attrs = append(
attrs, attrs,
slog.String("url", url), slog.String("url", requestUri),
slog.String("method", method), slog.String("method", method),
slog.Int("status", status), slog.Int("status", status),
slog.String("auth", requestAuth), slog.String("auth", requestAuth),
@@ -355,7 +356,14 @@ func logRequest(app core.App, c echo.Context, err *ApiError) {
// don't block on logs write // don't block on logs write
routine.FireAndForget(func() { routine.FireAndForget(func() {
message := method + " " + url message := method + " "
if escaped, err := url.PathUnescape(requestUri); err == nil {
message += escaped
} else {
message += requestUri
}
if err != nil { if err != nil {
app.Logger().Error(message, attrs...) app.Logger().Error(message, attrs...)
} else { } else {
@@ -394,6 +402,8 @@ func realUserIp(r *http.Request, fallbackIp string) string {
return fallbackIp 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 // 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. // context to allow reading for example the json request body data more than once.
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc { func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
+16
View File
@@ -10,6 +10,8 @@ import (
) )
func TestRequireGuestOnly(t *testing.T) { func TestRequireGuestOnly(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "valid record token", Name: "valid record token",
@@ -104,6 +106,8 @@ func TestRequireGuestOnly(t *testing.T) {
} }
func TestRequireRecordAuth(t *testing.T) { func TestRequireRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -242,6 +246,8 @@ func TestRequireRecordAuth(t *testing.T) {
} }
func TestRequireSameContextRecordAuth(t *testing.T) { func TestRequireSameContextRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -358,6 +364,8 @@ func TestRequireSameContextRecordAuth(t *testing.T) {
} }
func TestRequireAdminAuth(t *testing.T) { func TestRequireAdminAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -452,6 +460,8 @@ func TestRequireAdminAuth(t *testing.T) {
} }
func TestRequireAdminAuthOnlyIfAny(t *testing.T) { func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest (while having at least 1 existing admin)", Name: "guest (while having at least 1 existing admin)",
@@ -571,6 +581,8 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
} }
func TestRequireAdminOrRecordAuth(t *testing.T) { func TestRequireAdminOrRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -731,6 +743,8 @@ func TestRequireAdminOrRecordAuth(t *testing.T) {
} }
func TestRequireAdminOrOwnerAuth(t *testing.T) { func TestRequireAdminOrOwnerAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -869,6 +883,8 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
} }
func TestLoadCollectionContext(t *testing.T) { func TestLoadCollectionContext(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
+13 -3
View File
@@ -231,7 +231,12 @@ func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel model
if clientModel != nil && if clientModel != nil &&
clientModel.TableName() == newModel.TableName() && clientModel.TableName() == newModel.TableName() &&
clientModel.GetId() == newModel.GetId() { clientModel.GetId() == newModel.GetId() {
client.Set(contextKey, newModel) record, ok := newModel.(*models.Record)
if ok && record.OriginalCopy().TokenKey() != record.TokenKey() {
client.Unset(contextKey)
} else {
client.Set(contextKey, newModel)
}
} }
} }
@@ -245,7 +250,7 @@ func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model mo
if clientModel != nil && if clientModel != nil &&
clientModel.TableName() == model.TableName() && clientModel.TableName() == model.TableName() &&
clientModel.GetId() == model.GetId() { clientModel.GetId() == model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id()) client.Unset(contextKey)
} }
} }
@@ -371,7 +376,7 @@ type recordData struct {
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error { func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
collection := record.Collection() collection := record.Collection()
if collection == nil { if collection == nil {
return errors.New("[broadcastRecord] Record collection not set.") return errors.New("[broadcastRecord] Record collection not set")
} }
clients := api.app.SubscriptionsBroker().Clients() clients := api.app.SubscriptionsBroker().Clients()
@@ -409,6 +414,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
// mock request data // mock request data
requestInfo := &models.RequestInfo{ requestInfo := &models.RequestInfo{
Context: models.RequestInfoContextRealtime,
Method: "GET", Method: "GET",
Query: options.Query, Query: options.Query,
Headers: options.Headers, Headers: options.Headers,
@@ -568,6 +574,10 @@ func (api *realtimeApi) canAccessRecord(
return true // no further checks needed return true // no further checks needed
} }
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
return false
}
ruleFunc := func(q *dbx.SelectQuery) error { ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false) resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false)
+135 -21
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -22,6 +23,7 @@ func TestRealtimeConnect(t *testing.T) {
{ {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/realtime", Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`id:`, `id:`,
@@ -44,6 +46,7 @@ func TestRealtimeConnect(t *testing.T) {
Name: "PB_CONNECT interrupt", Name: "PB_CONNECT interrupt",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/realtime", Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1, "OnRealtimeConnectRequest": 1,
@@ -68,6 +71,7 @@ func TestRealtimeConnect(t *testing.T) {
Name: "Skipping/ignoring messages", Name: "Skipping/ignoring messages",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/realtime", Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1, "OnRealtimeConnectRequest": 1,
@@ -245,22 +249,48 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
apis.InitApi(testApp) apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
client := subscriptions.NewDefaultClient() authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test2@example.com")
client.Set(apis.ContextAuthRecordKey, authRecord) if err != nil {
testApp.SubscriptionsBroker().Register(client) t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client1)
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client2)
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.ContextAuthRecordKey, authRecord2)
testApp.SubscriptionsBroker().Register(client3)
// "delete" authRecord1
e := new(core.ModelEvent) e := new(core.ModelEvent)
e.Dao = testApp.Dao() e.Dao = testApp.Dao()
e.Model = authRecord e.Model = authRecord1
testApp.OnModelAfterDelete().Trigger(e) testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 { if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) t.Fatalf("Expected %d subscription clients, found %d", 3, total)
}
if auth := client1.Get(apis.ContextAuthRecordKey); auth != nil {
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
}
if auth := client2.Get(apis.ContextAuthRecordKey); auth != nil {
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
}
if auth := client3.Get(apis.ContextAuthRecordKey); auth == nil || auth.(*models.Record).Id != authRecord2.Id {
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
} }
} }
@@ -297,28 +327,87 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
} }
} }
func TestRealtimeAuthRecordUnsetOnTokenKeyRefresh(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord and refresh its tokenKey
authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
authRecord2.RefreshTokenKey()
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = authRecord2
testApp.OnModelAfterUpdate().Trigger(e)
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord != nil {
t.Fatalf("Expected authRecord to be unset, got %q", clientAuthRecord.Email())
}
}
func TestRealtimeAdminDeleteEvent(t *testing.T) { func TestRealtimeAdminDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
apis.InitApi(testApp) apis.InitApi(testApp)
admin, err := testApp.Dao().FindAdminByEmail("test@example.com") admin1, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
client := subscriptions.NewDefaultClient() admin2, err := testApp.Dao().FindAdminByEmail("test2@example.com")
client.Set(apis.ContextAdminKey, admin) if err != nil {
testApp.SubscriptionsBroker().Register(client) t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.ContextAdminKey, admin1)
testApp.SubscriptionsBroker().Register(client1)
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.ContextAdminKey, admin1)
testApp.SubscriptionsBroker().Register(client2)
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.ContextAdminKey, admin2)
testApp.SubscriptionsBroker().Register(client3)
// "delete" admin1
e := new(core.ModelEvent) e := new(core.ModelEvent)
e.Dao = testApp.Dao() e.Dao = testApp.Dao()
e.Model = admin e.Model = admin1
testApp.OnModelAfterDelete().Trigger(e) testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 { if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) t.Fatalf("Expected %d subscription clients, found %d", 3, total)
}
if auth := client1.Get(apis.ContextAdminKey); auth != nil {
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
}
if auth := client2.Get(apis.ContextAdminKey); auth != nil {
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
}
if auth := client3.Get(apis.ContextAdminKey); auth == nil || auth.(*models.Admin).Id != admin2.Id {
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
} }
} }
@@ -390,16 +479,29 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
apis.InitApi(testApp) apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
client := subscriptions.NewDefaultClient() authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test2@example.com")
client.Set(apis.ContextAuthRecordKey, authRecord) if err != nil {
testApp.SubscriptionsBroker().Register(client) t.Fatal(err)
}
// refetch the authRecord as CustomUser client1 := subscriptions.NewDefaultClient()
client1.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client1)
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client2)
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.ContextAuthRecordKey, authRecord2)
testApp.SubscriptionsBroker().Register(client3)
// refetch authRecord1 as CustomUser
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com") customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -410,8 +512,20 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if len(testApp.SubscriptionsBroker().Clients()) != 0 { if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) t.Fatalf("Expected %d subscription clients, found %d", 3, total)
}
if auth := client1.Get(apis.ContextAuthRecordKey); auth != nil {
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
}
if auth := client2.Get(apis.ContextAuthRecordKey); auth != nil {
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
}
if auth := client3.Get(apis.ContextAuthRecordKey); auth == nil || auth.(*models.Record).Id != authRecord2.Id {
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
} }
} }
+91 -18
View File
@@ -7,19 +7,23 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"sort" "sort"
"time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/auth" "github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@@ -30,6 +34,7 @@ func bindRecordAuthApi(app core.App, rg *echo.Group) {
// global oauth2 subscription redirect handler // global oauth2 subscription redirect handler
rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect) 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 // common collection record related routes
subGroup := rg.Group( subGroup := rg.Group(
@@ -117,7 +122,7 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
provider, err := auth.NewProviderByName(name) provider, err := auth.NewProviderByName(name)
if err != nil { 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 continue // skip provider
} }
@@ -146,7 +151,7 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
switch name { switch name {
case auth.NameApple: 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 // 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() { if provider.PKCE() {
@@ -201,13 +206,14 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
event.HttpContext = c event.HttpContext = c
event.Collection = collection event.Collection = collection
event.ProviderName = form.Provider event.ProviderName = form.Provider
event.IsNewRecord = false
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error { form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error { return createForm.DrySubmit(func(txDao *daos.Dao) error {
event.IsNewRecord = true event.IsNewRecord = true
// clone the current request data and assign the form create data as its body data // clone the current request data and assign the form create data as its body data
requestInfo := *RequestInfo(c) requestInfo := *RequestInfo(c)
requestInfo.Context = models.RequestInfoContextOAuth2
requestInfo.Data = form.CreateData requestInfo.Data = form.CreateData
createRuleFunc := func(q *dbx.SelectQuery) error { createRuleFunc := func(q *dbx.SelectQuery) error {
@@ -246,6 +252,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
event.Record = data.Record event.Record = data.Record
event.OAuth2User = data.OAuth2User event.OAuth2User = data.OAuth2User
event.ProviderClient = data.ProviderClient event.ProviderClient = data.ProviderClient
event.IsNewRecord = data.Record == nil
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error { return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
data.Record = e.Record 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 { 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) 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) 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 api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil) 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 { func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
state := c.QueryParam("state") redirectStatusCode := http.StatusTemporaryRedirect
code := c.QueryParam("code") if c.Request().Method != http.MethodGet {
redirectStatusCode = http.StatusSeeOther
if code == "" || state == "" {
return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
} }
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) { if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err) api.app.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State)
} return c.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
data := map[string]string{
"state": state,
"code": code,
} }
defer client.Unsubscribe(oauth2SubscriptionTopic)
encodedData, err := json.Marshal(data) encodedData, err := json.Marshal(data)
if err != nil { 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{ msg := subscriptions.Message{
@@ -688,5 +756,10 @@ func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
client.Send(msg) 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)
} }
+389 -91
View File
@@ -17,6 +17,8 @@ import (
) )
func TestRecordAuthMethodsList(t *testing.T) { func TestRecordAuthMethodsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -71,6 +73,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
} }
func TestRecordAuthWithPassword(t *testing.T) { func TestRecordAuthWithPassword(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "invalid body format", Name: "invalid body format",
@@ -233,6 +237,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1, "OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1, "OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1, "OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
}, },
}, },
@@ -300,6 +307,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1, "OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1, "OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1, "OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
}, },
}, },
{ {
@@ -324,6 +334,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordBeforeAuthWithPasswordRequest": 1, "OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1, "OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1, "OnRecordAuthRequest": 1,
// lastLoginAlertSentAt update
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
}, },
}, },
@@ -356,6 +369,8 @@ func TestRecordAuthWithPassword(t *testing.T) {
} }
func TestRecordAuthRefresh(t *testing.T) { func TestRecordAuthRefresh(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -483,6 +498,8 @@ func TestRecordAuthRefresh(t *testing.T) {
} }
func TestRecordAuthRequestPasswordReset(t *testing.T) { func TestRecordAuthRequestPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "not an auth collection", Name: "not an auth collection",
@@ -568,6 +585,8 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
} }
func TestRecordAuthConfirmPasswordReset(t *testing.T) { func TestRecordAuthConfirmPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -634,7 +653,55 @@ 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(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345678"
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
// external auths deletion
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
},
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, Method: http.MethodPost,
Url: "/api/collections/users/confirm-password-reset", Url: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{ Body: strings.NewReader(`{
@@ -649,6 +716,95 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
"OnRecordBeforeConfirmPasswordResetRequest": 1, "OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 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)
}
oldTokenKey := user.TokenKey()
// ensure that the user is already verified
user.SetVerified(true)
if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil {
t.Fatal("Failed to update user verified state")
}
// restore original token key that was reseted after the verified change
user.SetTokenKey(oldTokenKey)
if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil {
t.Fatal("Failed to reset user tokenKey")
}
},
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", Name: "OnRecordAfterConfirmPasswordResetRequest error response",
@@ -667,10 +823,13 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1, "OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1, "OnRecordAfterConfirmPasswordResetRequest": 1,
// external auths deletion
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
}, },
}, },
} }
@@ -681,6 +840,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
} }
func TestRecordAuthRequestVerification(t *testing.T) { func TestRecordAuthRequestVerification(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "not an auth collection", Name: "not an auth collection",
@@ -774,6 +935,8 @@ func TestRecordAuthRequestVerification(t *testing.T) {
} }
func TestRecordAuthConfirmVerification(t *testing.T) { func TestRecordAuthConfirmVerification(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -838,10 +1001,13 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
}`), }`),
ExpectedStatus: 204, ExpectedStatus: 204,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1, "OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1, "OnRecordAfterConfirmVerificationRequest": 1,
// external auths deletion
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
}, },
}, },
{ {
@@ -888,10 +1054,13 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1, "OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1, "OnRecordAfterConfirmVerificationRequest": 1,
// external auths deletion
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
}, },
}, },
} }
@@ -902,6 +1071,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
} }
func TestRecordAuthRequestEmailChange(t *testing.T) { func TestRecordAuthRequestEmailChange(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -1004,6 +1175,8 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
} }
func TestRecordAuthConfirmEmailChange(t *testing.T) { func TestRecordAuthConfirmEmailChange(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "not an auth collection", Name: "not an auth collection",
@@ -1074,10 +1247,13 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
}`), }`),
ExpectedStatus: 204, ExpectedStatus: 204,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnRecordBeforeConfirmEmailChangeRequest": 1, "OnRecordBeforeConfirmEmailChangeRequest": 1,
"OnRecordAfterConfirmEmailChangeRequest": 1, "OnRecordAfterConfirmEmailChangeRequest": 1,
// external auths deletion
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
}, },
}, },
{ {
@@ -1114,6 +1290,9 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmEmailChangeRequest": 1, "OnRecordBeforeConfirmEmailChangeRequest": 1,
"OnRecordAfterConfirmEmailChangeRequest": 1, "OnRecordAfterConfirmEmailChangeRequest": 1,
// external auths deletion
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
}, },
}, },
} }
@@ -1124,6 +1303,8 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
} }
func TestRecordAuthListExternalsAuths(t *testing.T) { func TestRecordAuthListExternalsAuths(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -1224,6 +1405,8 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
} }
func TestRecordAuthUnlinkExternalsAuth(t *testing.T) { func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -1353,114 +1536,229 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
} }
func TestRecordAuthOAuth2Redirect(t *testing.T) { func TestRecordAuthOAuth2Redirect(t *testing.T) {
c1 := subscriptions.NewDefaultClient() t.Parallel()
c2 := subscriptions.NewDefaultClient() clientStubs := make([]map[string]subscriptions.Client, 0, 10)
c2.Subscribe("@oauth2")
c3 := subscriptions.NewDefaultClient() for i := 0; i < 10; i++ {
c3.Subscribe("test1", "@oauth2") c1 := subscriptions.NewDefaultClient()
c4 := subscriptions.NewDefaultClient() c2 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2") c2.Subscribe("@oauth2")
c5 := subscriptions.NewDefaultClient() c3 := subscriptions.NewDefaultClient()
c5.Subscribe("@oauth2") c3.Subscribe("test1", "@oauth2")
c5.Discard()
beforeTestFunc := func(t *testing.T, app *tests.TestApp, e *echo.Echo) { c4 := subscriptions.NewDefaultClient()
app.SubscriptionsBroker().Register(c1) c4.Subscribe("test1", "test2")
app.SubscriptionsBroker().Register(c2)
app.SubscriptionsBroker().Register(c3) c5 := subscriptions.NewDefaultClient()
app.SubscriptionsBroker().Register(c4) c5.Subscribe("@oauth2")
app.SubscriptionsBroker().Register(c5) 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{ scenarios := []tests.ApiScenario{
{ {
Name: "no state query param", Name: "no state query param",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123", Url: "/api/oauth2-redirect?code=123",
ExpectedStatus: 400, BeforeTestFunc: beforeTestFunc(clientStubs[0], nil),
ExpectedContent: []string{`"data":{}`}, 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", Name: "invalid or missing client",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/oauth2-redirect?state=" + c3.Id(), Url: "/api/oauth2-redirect?code=123&state=missing",
ExpectedStatus: 400, BeforeTestFunc: beforeTestFunc(clientStubs[1], nil),
ExpectedContent: []string{`"data":{}`}, 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", Name: "no code query param",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=missing", Url: "/api/oauth2-redirect?state=" + clientStubs[2]["c3"].Id(),
ExpectedStatus: 404, BeforeTestFunc: beforeTestFunc(clientStubs[2], map[string][]string{
ExpectedContent: []string{`"data":{}`}, "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", Name: "error query param",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(), Url: "/api/oauth2-redirect?error=example&code=123&state=" + clientStubs[3]["c3"].Id(),
BeforeTestFunc: beforeTestFunc, BeforeTestFunc: beforeTestFunc(clientStubs[3], map[string][]string{
ExpectedStatus: 404, "c3": {`"state":"` + clientStubs[3]["c3"].Id(), `"code":"123"`, `"error":"example"`},
ExpectedContent: []string{`"data":{}`}, }),
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", Name: "discarded client with @oauth2 subscription",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(), Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[4]["c5"].Id(),
BeforeTestFunc: beforeTestFunc, BeforeTestFunc: beforeTestFunc(clientStubs[4], nil),
ExpectedStatus: 404, ExpectedStatus: http.StatusTemporaryRedirect,
ExpectedContent: []string{`"data":{}`}, 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", Name: "client with @oauth2 subscription",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(), Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[6]["c3"].Id(),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: beforeTestFunc(clientStubs[6], map[string][]string{
beforeTestFunc(t, app, e) "c3": {`"state":"` + clientStubs[6]["c3"].Id(), `"code":"123"`},
}),
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
}
}
}()
},
ExpectedStatus: http.StatusTemporaryRedirect, 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 package apis
import ( import (
cryptoRand "crypto/rand"
"fmt" "fmt"
"log/slog" "log/slog"
"math/big"
"net/http" "net/http"
"strings" "time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -43,13 +45,13 @@ func (api *recordApi) list(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.") return NewNotFoundError("", "Missing collection context.")
} }
requestInfo := RequestInfo(c)
// forbid users and guests to query special filter/sort fields // 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 return err
} }
requestInfo := RequestInfo(c)
if requestInfo.Admin == nil && collection.ListRule == nil { if requestInfo.Admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil // only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", 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{} 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 { if err != nil {
return NewBadRequestError("", err) 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())) 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) 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 { func (api *recordApi) view(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection) collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil { 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) 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 { createRuleFunc := func(q *dbx.SelectQuery) error {
if *collection.CreateRule == "" { if *collection.CreateRule == "" {
return nil // no create rule to resolve 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/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types"
) )
func TestRecordCrudList(t *testing.T) { func TestRecordCrudList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -43,9 +47,16 @@ func TestRecordCrudList(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, 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, 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, ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
@@ -486,6 +497,8 @@ func TestRecordCrudList(t *testing.T) {
} }
func TestRecordCrudView(t *testing.T) { func TestRecordCrudView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -765,6 +778,8 @@ func TestRecordCrudView(t *testing.T) {
} }
func TestRecordCrudDelete(t *testing.T) { func TestRecordCrudDelete(t *testing.T) {
t.Parallel()
ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) { ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId) storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
@@ -1008,6 +1023,8 @@ func TestRecordCrudDelete(t *testing.T) {
} }
func TestRecordCrudCreate(t *testing.T) { func TestRecordCrudCreate(t *testing.T) {
t.Parallel()
formData, mp, err := tests.MockMultipartData(map[string]string{ formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test", "title": "title_test",
}, "files") }, "files")
@@ -1015,6 +1032,20 @@ func TestRecordCrudCreate(t *testing.T) {
t.Fatal(err) 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{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -1222,6 +1253,60 @@ func TestRecordCrudCreate(t *testing.T) {
"OnModelAfterCreate": 1, "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", Name: "unique field error check",
Method: http.MethodPost, Method: http.MethodPost,
@@ -1584,6 +1669,8 @@ func TestRecordCrudCreate(t *testing.T) {
} }
func TestRecordCrudUpdate(t *testing.T) { func TestRecordCrudUpdate(t *testing.T) {
t.Parallel()
formData, mp, err := tests.MockMultipartData(map[string]string{ formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test", "title": "title_test",
}, "files") }, "files")
@@ -1591,6 +1678,20 @@ func TestRecordCrudUpdate(t *testing.T) {
t.Fatal(err) 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{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -1813,6 +1914,60 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnModelAfterUpdate": 1, "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", Name: "OnRecordAfterUpdateRequest error response",
Method: http.MethodPatch, Method: http.MethodPatch,
+35 -6
View File
@@ -44,6 +44,7 @@ func RequestInfo(c echo.Context) *models.RequestInfo {
} }
result := &models.RequestInfo{ result := &models.RequestInfo{
Context: models.RequestInfoContextDefault,
Method: c.Request().Method, Method: c.Request().Method,
Query: map[string]any{}, Query: map[string]any{},
Data: map[string]any{}, Data: map[string]any{},
@@ -78,7 +79,7 @@ func RecordAuthResponse(
finalizers ...func(token string) error, finalizers ...func(token string) error,
) error { ) error {
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified { 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) token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
@@ -140,7 +141,7 @@ func RecordAuthResponse(
// EnrichRecord parses the request context and enrich the provided record: // EnrichRecord parses the request context and enrich the provided record:
// - expands relations (if defaultExpands and/or ?expand query param is set) // - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth record and its expanded auth relations // - 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 { func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...) 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: // EnrichRecords parses the request context and enriches the provided records:
// - expands relations (if defaultExpands and/or ?expand query param is set) // - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth records and their expanded auth relations // - 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 { func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
requestInfo := RequestInfo(c) requestInfo := RequestInfo(c)
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil { 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 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)) errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
if len(errs) > 0 { if len(errs) > 0 {
return fmt.Errorf("Failed to expand: %v", errs) return fmt.Errorf("failed to expand: %v", errs)
} }
return nil return nil
@@ -184,7 +185,7 @@ func expandFetch(
} }
if relCollection.ViewRule == nil { 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 != "" { if *relCollection.ViewRule != "" {
@@ -314,3 +315,31 @@ func hasAuthManageAccess(
return findErr == nil 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) { func TestRequestInfo(t *testing.T) {
t.Parallel()
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`)) req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
@@ -67,6 +69,8 @@ func TestRequestInfo(t *testing.T) {
} }
func TestRecordAuthResponse(t *testing.T) { func TestRecordAuthResponse(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -83,7 +87,7 @@ func TestRecordAuthResponse(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
unverfiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md") unverifiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -104,7 +108,7 @@ func TestRecordAuthResponse(t *testing.T) {
}, },
{ {
name: "valid auth record but with unverified email in onlyVerified collection", name: "valid auth record but with unverified email in onlyVerified collection",
record: unverfiedAuthRecord, record: unverifiedAuthRecord,
expectError: true, expectError: true,
}, },
{ {
@@ -189,6 +193,8 @@ func TestRecordAuthResponse(t *testing.T) {
} }
func TestEnrichRecords(t *testing.T) { func TestEnrichRecords(t *testing.T) {
t.Parallel()
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil) req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 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 { BaseContext: func(l net.Listener) context.Context {
return baseCtx return baseCtx
}, },
ErrorLog: log.New(&serverErrorLogWriter{app: app}, "", 0),
} }
serveEvent := &core.ServeEvent{ serveEvent := &core.ServeEvent{
@@ -275,3 +276,13 @@ func runMigrations(app core.App) error {
return nil 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) { func TestSettingsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -82,6 +84,8 @@ func TestSettingsList(t *testing.T) {
`"yandexAuth":{`, `"yandexAuth":{`,
`"patreonAuth":{`, `"patreonAuth":{`,
`"mailcowAuth":{`, `"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
}, },
@@ -97,6 +101,8 @@ func TestSettingsList(t *testing.T) {
} }
func TestSettingsSet(t *testing.T) { func TestSettingsSet(t *testing.T) {
t.Parallel()
validData := `{"meta":{"appName":"update_test"}}` validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
@@ -165,6 +171,8 @@ func TestSettingsSet(t *testing.T) {
`"yandexAuth":{`, `"yandexAuth":{`,
`"patreonAuth":{`, `"patreonAuth":{`,
`"mailcowAuth":{`, `"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
`"appName":"acme_test"`, `"appName":"acme_test"`,
@@ -237,6 +245,8 @@ func TestSettingsSet(t *testing.T) {
`"yandexAuth":{`, `"yandexAuth":{`,
`"patreonAuth":{`, `"patreonAuth":{`,
`"mailcowAuth":{`, `"mailcowAuth":{`,
`"bitbucketAuth":{`,
`"planningcenterAuth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
`"appName":"update_test"`, `"appName":"update_test"`,
@@ -278,6 +288,8 @@ func TestSettingsSet(t *testing.T) {
} }
func TestSettingsTestS3(t *testing.T) { func TestSettingsTestS3(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -344,6 +356,8 @@ func TestSettingsTestS3(t *testing.T) {
} }
func TestSettingsTestEmail(t *testing.T) { func TestSettingsTestEmail(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -508,6 +522,8 @@ func TestSettingsTestEmail(t *testing.T) {
} }
func TestGenerateAppleClientSecret(t *testing.T) { func TestGenerateAppleClientSecret(t *testing.T) {
t.Parallel()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
t.Fatal(err) 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 { func adminCreateCommand(app core.App) *cobra.Command {
command := &cobra.Command{ command := &cobra.Command{
Use: "create", Use: "create",
Example: "admin create test@example.com 1234567890", Example: "admin create test@example.com 1234567890",
Short: "Creates a new admin account", Short: "Creates a new admin account",
// prevents printing the error log twice SilenceUsage: true,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error { RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 { if len(args) != 2 {
return errors.New("Missing email and password arguments.") return errors.New("Missing email and password arguments.")
@@ -51,6 +49,10 @@ func adminCreateCommand(app core.App) *cobra.Command {
admin.Email = args[0] admin.Email = args[0]
admin.SetPassword(args[1]) 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 { if err := app.Dao().SaveAdmin(admin); err != nil {
return fmt.Errorf("Failed to create new admin account: %v", err) 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 { func adminUpdateCommand(app core.App) *cobra.Command {
command := &cobra.Command{ command := &cobra.Command{
Use: "update", Use: "update",
Example: "admin update test@example.com 1234567890", Example: "admin update test@example.com 1234567890",
Short: "Changes the password of a single admin account", Short: "Changes the password of a single admin account",
// prevents printing the error log twice SilenceUsage: true,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error { RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 { if len(args) != 2 {
return errors.New("Missing email and password arguments.") 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.") 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]) admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil { if err != nil {
return fmt.Errorf("Admin with email %s doesn't exist.", args[0]) 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 { func adminDeleteCommand(app core.App) *cobra.Command {
command := &cobra.Command{ command := &cobra.Command{
Use: "delete", Use: "delete",
Example: "admin delete test@example.com", Example: "admin delete test@example.com",
Short: "Deletes an existing admin account", Short: "Deletes an existing admin account",
// prevents printing the error log twice SilenceUsage: true,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error { RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil { if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.") return errors.New("Invalid or missing email address.")
} }
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]) admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil { if err != nil {
color.Yellow("Admin %s is already deleted.", args[0]) color.Yellow("Admin %s is already deleted.", args[0])
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestAdminCreateCommand(t *testing.T) { func TestAdminCreateCommand(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+10 -7
View File
@@ -1,7 +1,7 @@
package cmd package cmd
import ( import (
"log" "errors"
"net/http" "net/http"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
@@ -17,10 +17,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
var httpsAddr string var httpsAddr string
command := &cobra.Command{ command := &cobra.Command{
Use: "serve [domain(s)]", Use: "serve [domain(s)]",
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)", Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
Run: func(command *cobra.Command, args []string) { SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
// set default listener addresses if at least one domain is specified // set default listener addresses if at least one domain is specified
if len(args) > 0 { if len(args) > 0 {
if httpAddr == "" { if httpAddr == "" {
@@ -43,9 +44,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
CertificateDomains: args, CertificateDomains: args,
}) })
if err != http.ErrServerClosed { if errors.Is(err, http.ErrServerClosed) {
log.Fatalln(err) return nil
} }
return err
}, },
} }
+5 -2
View File
@@ -59,6 +59,9 @@ type App interface {
// (used for settings encryption). // (used for settings encryption).
EncryptionEnv() string EncryptionEnv() string
// IsDev returns whether the app is in dev mode.
IsDev() bool
// Settings returns the loaded app settings. // Settings returns the loaded app settings.
Settings() *settings.Settings Settings() *settings.Settings
@@ -288,14 +291,14 @@ type App interface {
// SSE client connection. // SSE client connection.
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
// OnRealtimeBeforeMessage hook is triggered right before sending // OnRealtimeBeforeMessageSend hook is triggered right before sending
// an SSE message to a client. // an SSE message to a client.
// //
// Returning [hook.StopPropagation] will prevent sending the message. // Returning [hook.StopPropagation] will prevent sending the message.
// Returning any other non-nil error will close the realtime connection. // Returning any other non-nil error will close the realtime connection.
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeMessage hook is triggered right after sending // OnRealtimeAfterMessageSend hook is triggered right after sending
// an SSE message to a client. // an SSE message to a client.
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
+71 -23
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"syscall" "syscall"
"time" "time"
@@ -47,6 +48,7 @@ type BaseApp struct {
// @todo consider introducing a mutex to allow safe concurrent config changes during runtime // @todo consider introducing a mutex to allow safe concurrent config changes during runtime
// configurable parameters // configurable parameters
isDev bool
dataDir string dataDir string
encryptionEnv string encryptionEnv string
dataMaxOpenConns int dataMaxOpenConns int
@@ -174,6 +176,7 @@ type BaseApp struct {
// BaseAppConfig defines a BaseApp configuration option // BaseAppConfig defines a BaseApp configuration option
type BaseAppConfig struct { type BaseAppConfig struct {
IsDev bool
DataDir string DataDir string
EncryptionEnv string EncryptionEnv string
DataMaxOpenConns int // default to 500 DataMaxOpenConns int // default to 500
@@ -188,6 +191,7 @@ type BaseAppConfig struct {
// To initialize the app, you need to call `app.Bootstrap()`. // To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config BaseAppConfig) *BaseApp { func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{ app := &BaseApp{
isDev: config.IsDev,
dataDir: config.DataDir, dataDir: config.DataDir,
encryptionEnv: config.EncryptionEnv, encryptionEnv: config.EncryptionEnv,
dataMaxOpenConns: config.DataMaxOpenConns, dataMaxOpenConns: config.DataMaxOpenConns,
@@ -458,6 +462,13 @@ func (app *BaseApp) EncryptionEnv() string {
return app.encryptionEnv 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. // Settings returns the loaded app settings.
func (app *BaseApp) Settings() *settings.Settings { func (app *BaseApp) Settings() *settings.Settings {
return app.settings return app.settings
@@ -590,8 +601,10 @@ func (app *BaseApp) RefreshSettings() error {
} }
// reload handler level (if initialized) // reload handler level (if initialized)
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok { if app.Logger() != nil {
h.SetLevel(slog.Level(app.settings.Logs.MinLevel)) if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
h.SetLevel(app.getLoggerMinLevel())
}
} }
return nil return nil
@@ -1054,15 +1067,16 @@ func (app *BaseApp) initDataDB() error {
nonconcurrentDB.DB().SetMaxIdleConns(1) nonconcurrentDB.DB().SetMaxIdleConns(1)
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute) nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
// @todo benchmark whether it will have an impact if always enabled as TRACE log if app.IsDev() {
// nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { 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) 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) {
// 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)
// color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql) }
// } concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
// concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
}
app.dao = app.createDaoWithHooks(concurrentDB, nonconcurrentDB) 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 // try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDelete().Add(func(e *ModelEvent) error { app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" { 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 // run in the background for "optimistic" delete to avoid
// blocking the delete transaction // blocking the delete transaction
@@ -1169,6 +1185,29 @@ func (app *BaseApp) registerDefaultHooks() {
if err := app.initAutobackupHooks(); err != nil { if err := app.initAutobackupHooks(); err != nil {
app.Logger().Error("Failed to init auto backup hooks", slog.String("error", err.Error())) 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 { func (app *BaseApp) initLogger() error {
@@ -1176,15 +1215,19 @@ func (app *BaseApp) initLogger() error {
ticker := time.NewTicker(duration) ticker := time.NewTicker(duration)
done := make(chan bool) done := make(chan bool)
level := slog.LevelInfo
if app.Settings() != nil {
level = slog.Level(app.Settings().Logs.MinLevel)
}
handler := logger.NewBatchHandler(logger.BatchOptions{ handler := logger.NewBatchHandler(logger.BatchOptions{
Level: level, Level: app.getLoggerMinLevel(),
BatchSize: 200, BatchSize: 200,
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool { 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) ticker.Reset(duration)
return app.Settings().Logs.MaxDays > 0 return app.Settings().Logs.MaxDays > 0
@@ -1216,14 +1259,14 @@ func (app *BaseApp) initLogger() error {
return nil return nil
}) })
// @todo replace with cron so that it doesn't rely on the logs write
//
// delete old logs // delete old logs
// --- // ---
logsMaxDays := app.Settings().Logs.MaxDays
now := time.Now() now := time.Now()
lastLogsDeletedAt := cast.ToTime(app.Store().Get("lastLogsDeletedAt")) lastLogsDeletedAt := cast.ToTime(app.Store().Get("lastLogsDeletedAt"))
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24 if now.Sub(lastLogsDeletedAt).Hours() >= 6 {
if daysDiff > float64(logsMaxDays) { deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*logsMaxDays))
if deleteErr == nil { if deleteErr == nil {
app.Store().Set("lastLogsDeletedAt", now) app.Store().Set("lastLogsDeletedAt", now)
} else { } else {
@@ -1241,7 +1284,7 @@ func (app *BaseApp) initLogger() error {
for { for {
select { select {
case <-done: case <-done:
handler.WriteAll(ctx) return
case <-ticker.C: case <-ticker.C:
handler.WriteAll(ctx) handler.WriteAll(ctx)
} }
@@ -1251,8 +1294,13 @@ func (app *BaseApp) initLogger() error {
app.logger = slog.New(handler) app.logger = slog.New(handler)
app.OnTerminate().PreAdd(func(e *TerminateEvent) error { 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() ticker.Stop()
done <- true done <- true
return nil 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). // Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
// --- // ---
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4)) tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error { createErr := app.Dao().RunInTransaction(func(dataTXDao *daos.Dao) error {
// @todo consider experimenting with temp switching the readonly pragma after the db interface change return app.LogsDao().RunInTransaction(func(logsTXDao *daos.Dao) error {
return archive.Create(app.DataDir(), tempPath, exclude...) // 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 { if createErr != nil {
return createErr return createErr
@@ -248,6 +254,16 @@ func (app *BaseApp) initAutobackupHooks() error {
loadJob := func() { loadJob := func() {
c.Stop() 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 rawSchedule := app.Settings().Backups.Cron
if rawSchedule == "" || !isServe || !app.IsBootstrapped() { if rawSchedule == "" || !isServe || !app.IsBootstrapped() {
return return
+353 -57
View File
@@ -1,14 +1,25 @@
package core package core
import ( import (
"context"
"database/sql"
"fmt"
"log/slog"
"os" "os"
"strings"
"testing" "testing"
"time" "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/migrations/logs"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/logger" "github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/migrate" "github.com/pocketbase/pocketbase/tools/migrate"
"github.com/pocketbase/pocketbase/tools/types"
) )
func TestNewBaseApp(t *testing.T) { func TestNewBaseApp(t *testing.T) {
@@ -18,6 +29,7 @@ func TestNewBaseApp(t *testing.T) {
app := NewBaseApp(BaseAppConfig{ app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
EncryptionEnv: "test_env", EncryptionEnv: "test_env",
IsDev: true,
}) })
if app.dataDir != testDataDir { if app.dataDir != testDataDir {
@@ -28,6 +40,10 @@ func TestNewBaseApp(t *testing.T) {
t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir) 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 { if app.store == nil {
t.Fatal("expected store to be set, got nil") t.Fatal("expected store to be set, got nil")
} }
@@ -132,6 +148,7 @@ func TestBaseAppGetters(t *testing.T) {
app := NewBaseApp(BaseAppConfig{ app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
EncryptionEnv: "pb_test_env", EncryptionEnv: "pb_test_env",
IsDev: true,
}) })
defer app.ResetBootstrapState() defer app.ResetBootstrapState()
@@ -163,6 +180,10 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv) 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() { if app.settings != app.Settings() {
t.Fatalf("Expected app.Settings %v, got %v", 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) { func TestBaseAppNewMailClient(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" app, cleanup, err := initTestBaseApp()
defer os.RemoveAll(testDataDir) if err != nil {
t.Fatal(err)
app := NewBaseApp(BaseAppConfig{ }
DataDir: testDataDir, defer cleanup()
EncryptionEnv: "pb_test_env",
})
client1 := app.NewMailClient() client1 := app.NewMailClient()
if val, ok := client1.(*mailer.Sendmail); !ok { if val, ok := client1.(*mailer.Sendmail); !ok {
@@ -207,13 +226,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
} }
func TestBaseAppNewFilesystem(t *testing.T) { func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" app, cleanup, err := initTestBaseApp()
defer os.RemoveAll(testDataDir) if err != nil {
t.Fatal(err)
app := NewBaseApp(BaseAppConfig{ }
DataDir: testDataDir, defer cleanup()
EncryptionEnv: "pb_test_env",
})
// local // local
local, localErr := app.NewFilesystem() local, localErr := app.NewFilesystem()
@@ -236,13 +253,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
} }
func TestBaseAppNewBackupsFilesystem(t *testing.T) { func TestBaseAppNewBackupsFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" app, cleanup, err := initTestBaseApp()
defer os.RemoveAll(testDataDir) if err != nil {
t.Fatal(err)
app := NewBaseApp(BaseAppConfig{ }
DataDir: testDataDir, defer cleanup()
EncryptionEnv: "pb_test_env",
})
// local // local
local, localErr := app.NewBackupsFilesystem() local, localErr := app.NewBackupsFilesystem()
@@ -265,49 +280,41 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
} }
func TestBaseAppLoggerWrites(t *testing.T) { func TestBaseAppLoggerWrites(t *testing.T) {
testDataDir, err := os.MkdirTemp("", "logger_writes") app, cleanup, err := initTestBaseApp()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer os.RemoveAll(testDataDir) defer cleanup()
app := NewBaseApp(BaseAppConfig{ const logsThreshold = 200
DataDir: testDataDir,
})
if err := app.Bootstrap(); err != nil { totalLogs := func(app App, t *testing.T) int {
t.Fatal(err) 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 t.Run("disabled logs retention", func(t *testing.T) {
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
{
app.Settings().Logs.MaxDays = 0 app.Settings().Logs.MaxDays = 0
for i := 0; i < threshold+1; i++ { for i := 0; i < logsThreshold+1; i++ {
app.Logger().Error("test") app.Logger().Error("test")
} }
if total := totalLogs(app, t); total != 0 { if total := totalLogs(app, t); total != 0 {
t.Fatalf("Expected no logs, got %d", total) 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 app.Settings().Logs.MaxDays = 1
for i := 0; i < threshold-1; i++ { for i := 0; i < logsThreshold-1; i++ {
app.Logger().Error("test") app.Logger().Error("test")
} }
@@ -321,25 +328,314 @@ func TestBaseAppLoggerWrites(t *testing.T) {
// should be added for the next batch write // should be added for the next batch write
app.Logger().Error("test") app.Logger().Error("test")
if total := totalLogs(app, t); total != threshold { if total := totalLogs(app, t); total != logsThreshold {
t.Fatalf("Expected %d logs, got %d", threshold, total) t.Fatalf("Expected %d logs, got %d", logsThreshold, total)
} }
// wait for ~3 secs to check the timer trigger // wait for ~3 secs to check the timer trigger
time.Sleep(3200 * time.Millisecond) time.Sleep(3200 * time.Millisecond)
if total := totalLogs(app, t); total != threshold+1 { if total := totalLogs(app, t); total != logsThreshold+1 {
t.Fatalf("Expected %d logs, got %d", threshold+1, total) 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 { func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
var total int app, cleanup, err := initTestBaseApp()
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
if err != nil { 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 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) { func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
// @todo consider caching the unverified claims // @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token) unverifiedClaims, err := security.ParseUnverifiedJWT(token)
@@ -59,7 +59,7 @@ func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Adm
// check required claims // check required claims
id, _ := unverifiedClaims["id"].(string) id, _ := unverifiedClaims["id"].(string)
if id == "" { 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) admin, err := dao.FindAdminById(id)
@@ -116,7 +116,7 @@ func (dao *Dao) DeleteAdmin(admin *models.Admin) error {
} }
if total == 1 { 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) return dao.Delete(admin)
+16
View File
@@ -8,6 +8,8 @@ import (
) )
func TestAdminQuery(t *testing.T) { func TestAdminQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -20,6 +22,8 @@ func TestAdminQuery(t *testing.T) {
} }
func TestFindAdminById(t *testing.T) { func TestFindAdminById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -47,6 +51,8 @@ func TestFindAdminById(t *testing.T) {
} }
func TestFindAdminByEmail(t *testing.T) { func TestFindAdminByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -76,6 +82,8 @@ func TestFindAdminByEmail(t *testing.T) {
} }
func TestFindAdminByToken(t *testing.T) { func TestFindAdminByToken(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -131,6 +139,8 @@ func TestFindAdminByToken(t *testing.T) {
} }
func TestTotalAdmins(t *testing.T) { func TestTotalAdmins(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -155,6 +165,8 @@ func TestTotalAdmins(t *testing.T) {
} }
func TestIsAdminEmailUnique(t *testing.T) { func TestIsAdminEmailUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -180,6 +192,8 @@ func TestIsAdminEmailUnique(t *testing.T) {
} }
func TestDeleteAdmin(t *testing.T) { func TestDeleteAdmin(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -225,6 +239,8 @@ func TestDeleteAdmin(t *testing.T) {
} }
func TestSaveAdmin(t *testing.T) { func TestSaveAdmin(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+14 -14
View File
@@ -18,7 +18,7 @@ func New(db dbx.Builder) *Dao {
return NewMultiDB(db, db) 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. // async and sync db builders.
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao { func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
return &Dao{ return &Dao{
@@ -87,16 +87,16 @@ func (dao *Dao) Clone() *Dao {
// WithoutHooks returns a new Dao with the same configuration options // WithoutHooks returns a new Dao with the same configuration options
// as the current one, but without create/update/delete hooks. // as the current one, but without create/update/delete hooks.
func (dao *Dao) WithoutHooks() *Dao { func (dao *Dao) WithoutHooks() *Dao {
new := dao.Clone() clone := dao.Clone()
new.BeforeCreateFunc = nil clone.BeforeCreateFunc = nil
new.AfterCreateFunc = nil clone.AfterCreateFunc = nil
new.BeforeUpdateFunc = nil clone.BeforeUpdateFunc = nil
new.AfterUpdateFunc = nil clone.AfterUpdateFunc = nil
new.BeforeDeleteFunc = nil clone.BeforeDeleteFunc = nil
new.AfterDeleteFunc = nil clone.AfterDeleteFunc = nil
return new return clone
} }
// ModelQuery creates a new preconfigured select query with preset // 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 { type afterCallGroup struct {
Action string
EventDao *Dao
Model models.Model Model models.Model
EventDao *Dao
Action string
} }
// RunInTransaction wraps fn into a transaction. // RunInTransaction wraps fn into a transaction.
@@ -169,19 +169,19 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
if dao.AfterCreateFunc != nil { if dao.AfterCreateFunc != nil {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error { 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 return nil
} }
} }
if dao.AfterUpdateFunc != nil { if dao.AfterUpdateFunc != nil {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error { 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 return nil
} }
} }
if dao.AfterDeleteFunc != nil { if dao.AfterDeleteFunc != nil {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error { 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 return nil
} }
} }
+4
View File
@@ -6,6 +6,8 @@ import (
) )
func TestGetDefaultRetryInterval(t *testing.T) { func TestGetDefaultRetryInterval(t *testing.T) {
t.Parallel()
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 { if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i) t.Fatalf("Expected 1000ms, got %v", i)
} }
@@ -20,6 +22,8 @@ func TestGetDefaultRetryInterval(t *testing.T) {
} }
func TestBaseLockRetry(t *testing.T) { func TestBaseLockRetry(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
err error err error
failUntilAttempt int 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 // - is referenced as part of a relation field in another collection
func (dao *Dao) DeleteCollection(collection *models.Collection) error { func (dao *Dao) DeleteCollection(collection *models.Collection) error {
if collection.System { 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. // ensure that there aren't any existing references.
@@ -135,7 +135,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
for ref := range result { for ref := range result {
names = append(names, ref.Name) 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 { 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 // trigger views resave to check for dependencies
if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil { 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) return txDao.Delete(collection)
@@ -162,8 +162,8 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
// SaveCollection persists the provided Collection model and updates // SaveCollection persists the provided Collection model and updates
// its related records table schema. // its related records table schema.
// //
// If collecction.IsNew() is true, the method will perform a create, otherwise an update. // If collection.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a collection for update you can use collecction.MarkAsNotNew(). // To explicitly mark a collection for update you can use collection.MarkAsNotNew().
func (dao *Dao) SaveCollection(collection *models.Collection) error { func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection var oldCollection *models.Collection
@@ -227,7 +227,7 @@ func (dao *Dao) ImportCollections(
afterSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error, afterSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
) error { ) error {
if len(importedCollections) == 0 { 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 { return dao.RunInTransaction(func(txDao *Dao) error {
@@ -263,11 +263,11 @@ func (dao *Dao) ImportCollections(
// extend existing schema // extend existing schema
if !deleteMissing { if !deleteMissing {
schema, _ := existing.Schema.Clone() schemaClone, _ := existing.Schema.Clone()
for _, f := range imported.Schema.Fields() { 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 { } else {
imported.MarkAsNew() imported.MarkAsNew()
@@ -285,7 +285,7 @@ func (dao *Dao) ImportCollections(
} }
if existing.System { 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 // delete the related records table or view
+52 -27
View File
@@ -16,6 +16,8 @@ import (
) )
func TestCollectionQuery(t *testing.T) { func TestCollectionQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -28,6 +30,8 @@ func TestCollectionQuery(t *testing.T) {
} }
func TestFindCollectionsByType(t *testing.T) { func TestFindCollectionsByType(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -63,6 +67,8 @@ func TestFindCollectionsByType(t *testing.T) {
} }
func TestFindCollectionByNameOrId(t *testing.T) { func TestFindCollectionByNameOrId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
} }
func TestIsCollectionNameUnique(t *testing.T) { func TestIsCollectionNameUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -116,6 +124,8 @@ func TestIsCollectionNameUnique(t *testing.T) {
} }
func TestFindCollectionReferences(t *testing.T) { func TestFindCollectionReferences(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -143,9 +153,11 @@ func TestFindCollectionReferences(t *testing.T) {
"rel_one_no_cascade", "rel_one_no_cascade",
"rel_one_no_cascade_required", "rel_one_no_cascade_required",
"rel_one_cascade", "rel_one_cascade",
"rel_one_unique",
"rel_many_no_cascade", "rel_many_no_cascade",
"rel_many_no_cascade_required", "rel_many_no_cascade_required",
"rel_many_cascade", "rel_many_cascade",
"rel_many_unique",
} }
for col, fields := range result { for col, fields := range result {
@@ -164,6 +176,8 @@ func TestFindCollectionReferences(t *testing.T) {
} }
func TestDeleteCollection(t *testing.T) { func TestDeleteCollection(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -251,6 +265,8 @@ func TestDeleteCollection(t *testing.T) {
} }
func TestSaveCollectionCreate(t *testing.T) { func TestSaveCollectionCreate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -297,6 +313,8 @@ func TestSaveCollectionCreate(t *testing.T) {
} }
func TestSaveCollectionUpdate(t *testing.T) { func TestSaveCollectionUpdate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() 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 // indirect update of a field used in view should cause view(s) update
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) { func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -395,6 +415,8 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
} }
func TestSaveCollectionViewWrapping(t *testing.T) { func TestSaveCollectionViewWrapping(t *testing.T) {
t.Parallel()
viewName := "test_wrapping" viewName := "test_wrapping"
scenarios := []struct { scenarios := []struct {
@@ -504,6 +526,8 @@ func TestSaveCollectionViewWrapping(t *testing.T) {
} }
func TestImportCollections(t *testing.T) { func TestImportCollections(t *testing.T) {
t.Parallel()
totalCollections := 11 totalCollections := 11
scenarios := []struct { scenarios := []struct {
@@ -734,7 +758,7 @@ func TestImportCollections(t *testing.T) {
"demo1": 15, "demo1": 15,
"demo2": 2, "demo2": 2,
"demo3": 2, "demo3": 2,
"demo4": 11, "demo4": 13,
"demo5": 6, "demo5": 6,
"new_import": 1, "new_import": 1,
} }
@@ -752,37 +776,38 @@ func TestImportCollections(t *testing.T) {
}, },
} }
for _, scenario := range scenarios { for _, s := range scenarios {
testApp, _ := tests.NewTestApp() t.Run(s.name, func(t *testing.T) {
defer testApp.Cleanup() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
importedCollections := []*models.Collection{} importedCollections := []*models.Collection{}
// load data // load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections) loadErr := json.Unmarshal([]byte(s.jsonData), &importedCollections)
if loadErr != nil { if loadErr != nil {
t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr) t.Fatalf("Failed to load data: %v", loadErr)
continue }
}
err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync) err := testApp.Dao().ImportCollections(importedCollections, s.deleteMissing, s.beforeRecordsSync)
hasErr := err != nil hasErr := err != nil
if hasErr != scenario.expectError { if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err) t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
} }
// check collections count // check collections count
collections := []*models.Collection{} collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil { if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(collections) != scenario.expectCollectionsCount { if len(collections) != s.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections)) t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
} }
if scenario.afterTestFunc != nil { if s.afterTestFunc != nil {
scenario.afterTestFunc(testApp, collections) s.afterTestFunc(testApp, collections)
} }
})
} }
} }
+12
View File
@@ -9,6 +9,8 @@ import (
) )
func TestExternalAuthQuery(t *testing.T) { func TestExternalAuthQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -21,6 +23,8 @@ func TestExternalAuthQuery(t *testing.T) {
} }
func TestFindAllExternalAuthsByRecord(t *testing.T) { func TestFindAllExternalAuthsByRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -58,6 +62,8 @@ func TestFindAllExternalAuthsByRecord(t *testing.T) {
} }
func TestFindFirstExternalAuthByExpr(t *testing.T) { func TestFindFirstExternalAuthByExpr(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -89,6 +95,8 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
} }
func TestFindExternalAuthByRecordAndProvider(t *testing.T) { func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -125,6 +133,8 @@ func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
} }
func TestSaveExternalAuth(t *testing.T) { func TestSaveExternalAuth(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -161,6 +171,8 @@ func TestSaveExternalAuth(t *testing.T) {
} }
func TestDeleteExternalAuth(t *testing.T) { func TestDeleteExternalAuth(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+10
View File
@@ -12,6 +12,8 @@ import (
) )
func TestLogQuery(t *testing.T) { func TestLogQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -24,6 +26,8 @@ func TestLogQuery(t *testing.T) {
} }
func TestFindLogById(t *testing.T) { func TestFindLogById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -54,6 +58,8 @@ func TestFindLogById(t *testing.T) {
} }
func TestLogsStats(t *testing.T) { func TestLogsStats(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -75,6 +81,8 @@ func TestLogsStats(t *testing.T) {
} }
func TestDeleteOldLogs(t *testing.T) { func TestDeleteOldLogs(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -115,6 +123,8 @@ func TestDeleteOldLogs(t *testing.T) {
} }
func TestSaveLog(t *testing.T) { func TestSaveLog(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+10
View File
@@ -11,6 +11,8 @@ import (
) )
func TestParamQuery(t *testing.T) { func TestParamQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -23,6 +25,8 @@ func TestParamQuery(t *testing.T) {
} }
func TestFindParamByKey(t *testing.T) { func TestFindParamByKey(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -50,6 +54,8 @@ func TestFindParamByKey(t *testing.T) {
} }
func TestSaveParam(t *testing.T) { func TestSaveParam(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestSaveParam(t *testing.T) {
} }
func TestSaveParamEncrypted(t *testing.T) { func TestSaveParamEncrypted(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -126,6 +134,8 @@ func TestSaveParamEncrypted(t *testing.T) {
} }
func TestDeleteParam(t *testing.T) { func TestDeleteParam(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+70 -20
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -39,16 +40,16 @@ func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
collection, collectionErr = dao.FindCollectionByNameOrId(c) collection, collectionErr = dao.FindCollectionByNameOrId(c)
if collection != nil { if collection != nil {
tableName = collection.Name tableName = collection.Name
} else {
// update with some fake table name for easier debugging
tableName = "@@__missing_" + c
} }
default: 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") 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)) selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
query := dao.DB().Select(selectCols).From(tableName) query := dao.DB().Select(selectCols).From(tableName)
@@ -197,8 +198,6 @@ func (dao *Dao) FindRecordsByIds(
return records, nil 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. // FindRecordsByExpr finds all records by the specified db expression.
// //
// Returns all collection records if no expressions are provided. // Returns all collection records if no expressions are provided.
@@ -409,9 +408,9 @@ func (dao *Dao) IsRecordValueUnique(
return query.Row(&exists) == nil && !exists 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) { func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token) unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil { if err != nil {
@@ -431,7 +430,7 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
} }
if !record.Collection().IsAuth() { 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 verificationKey := record.TokenKey() + baseTokenKey
@@ -585,6 +584,8 @@ func (dao *Dao) CanAccessRecord(record *models.Record, requestInfo *models.Reque
// If record.IsNew() is true, the method will perform a create, otherwise an update. // If record.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a record for update you can use record.MarkAsNotNew(). // To explicitly mark a record for update you can use record.MarkAsNotNew().
func (dao *Dao) SaveRecord(record *models.Record) error { func (dao *Dao) SaveRecord(record *models.Record) error {
var needToDeleteExternalAuths bool
if record.Collection().IsAuth() { if record.Collection().IsAuth() {
if record.Username() == "" { if record.Username() == "" {
return errors.New("unable to save auth record without username") return errors.New("unable to save auth record without username")
@@ -605,9 +606,44 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
return errors.New("the auth record ID must be unique across all auth collections") return errors.New("the auth record ID must be unique across all auth collections")
} }
} }
if !record.IsNew() && record.Verified() {
lastSavedRecord, err := dao.FindRecordById(record.Collection().Id, record.Id)
if err != nil {
return err
}
// in case upgrading from "unverified" -> "verified" mark all pre-existing OAuth2 links
// for deletion since there is no reliable way to verify that they weren't created by an attacker
if !lastSavedRecord.Verified() && record.Verified() {
needToDeleteExternalAuths = true
}
}
} }
return dao.Save(record) if !needToDeleteExternalAuths {
return dao.Save(record)
}
return dao.RunInTransaction(func(txDao *Dao) error {
externalAuths, err := txDao.FindAllExternalAuthsByRecord(record)
if err != nil {
return err
}
if len(externalAuths) > 0 {
// delete all pre-existing external auths
for _, ea := range externalAuths {
if err := txDao.DeleteExternalAuth(ea); err != nil {
return err
}
}
// force refresh tokens reset (if not already)
record.RefreshTokenKey()
}
return txDao.Save(record)
})
} }
// DeleteRecord deletes the provided Record model. // DeleteRecord deletes the provided Record model.
@@ -658,26 +694,40 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
// //
// NB! This method is expected to be called inside a transaction. // 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 { 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 { for _, refCollection := range sortedRefKeys {
if refCollection.IsView() { fields, ok := refs[refCollection]
continue // skip view collections
if refCollection.IsView() || !ok {
continue // skip missing or view collections
} }
for _, field := range fields { for _, field := range fields {
recordTableName := inflector.Columnify(refCollection.Name) recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.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() { if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id}) query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
} else { } else {
query.InnerJoin(fmt.Sprintf( query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`, `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, uniqueJsonEachAlias, prefixedFieldName, prefixedFieldName, prefixedFieldName,
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id}) ), dbx.Params{
"jevalue": mainRecord.Id,
})))
} }
if refCollection.Id == mainRecord.Collection().Id { if refCollection.Id == mainRecord.Collection().Id {
+61 -39
View File
@@ -1,7 +1,9 @@
package daos package daos
import ( import (
"errors"
"fmt" "fmt"
"log"
"regexp" "regexp"
"strings" "strings"
@@ -9,13 +11,14 @@ import (
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils" "github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
) )
// MaxExpandDepth specifies the max allowed nested expand depth path. // MaxExpandDepth specifies the max allowed nested expand depth path.
//
// @todo Consider eventually reusing resolvers.maxNestedRels
const MaxExpandDepth = 6 const MaxExpandDepth = 6
// ExpandFetchFunc defines the function that is used to fetch the expanded relation records. // 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 return failed
} }
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`) // Deprecated
var indirectExpandRegexOld = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
var indirectExpandRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
// notes: // notes:
// - if fetchFunc is nil, dao.FindRecordsByIds will be used // - if fetchFunc is nil, dao.FindRecordsByIds will be used
// - all records are expected to be from the same collection // - all records are expected to be from the same collection
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path // - 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 { func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
if fetchFunc == nil { if fetchFunc == nil {
// load a default fetchFunc // load a default fetchFunc
@@ -77,70 +82,87 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
var relCollection *models.Collection var relCollection *models.Collection
parts := strings.SplitN(expandPath, ".", 2) 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 { if len(matches) == 3 {
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1]) indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
if indirectRel == nil { 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]) indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation { 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() indirectRelField.InitOptions()
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions) indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id { if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0]) return fmt.Errorf("invalid back-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])
} }
recordIds := make([]any, len(records)) // add the related id(s) as a dynamic relation field value to
for i, record := range records { // allow further expand checks at later stage in a more unified manner
recordIds[i] = record.Id 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 if indirectRelFieldOptions.IsMultiple() {
// indirect expand for multi-relation fields q.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
indirectRecords, err := dao.FindRecordsByExpr( "SELECT 1 FROM %s je WHERE je.value = {:id}",
indirectRel.Id, dbutils.JsonEach(indirectRelField.Name),
dbx.In(inflector.Columnify(matches[2]), recordIds...), ))))
) } else {
if err != nil { q.AndWhere(dbx.NewExp("[[" + indirectRelField.Name + "]] = {:id}"))
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)
} }
}
// add the indirect relation ids as a new relation field value pq := q.Build().Prepare()
for _, record := range records {
relIds, ok := mappedIndirectRecordIds[record.Id] for _, record := range records {
if ok && len(relIds) > 0 { var relIds []string
record.Set(parts[0], relIds)
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{ relFieldOptions = &schema.RelationOptions{
MaxSelect: nil, MaxSelect: nil,
CollectionId: indirectRel.Id, CollectionId: indirectRel.Id,
} }
if isRelFieldUnique(indirectRel, indirectRelField.Name) { if dbutils.HasSingleColumnUniqueIndex(indirectRelField.Name, indirectRel.Indexes) {
relFieldOptions.MaxSelect = types.Pointer(1) relFieldOptions.MaxSelect = types.Pointer(1)
} }
// indirect relation // indirect/back relation
relField = &schema.SchemaField{ relField = &schema.SchemaField{
Id: "indirect_" + security.PseudorandomString(5), Id: "_" + parts[0] + security.PseudorandomString(3),
Type: schema.FieldTypeRelation, Type: schema.FieldTypeRelation,
Name: parts[0], Name: parts[0],
Options: relFieldOptions, Options: relFieldOptions,
+67 -13
View File
@@ -14,6 +14,8 @@ import (
) )
func TestExpandRecords(t *testing.T) { func TestExpandRecords(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -161,7 +163,7 @@ func TestExpandRecords(t *testing.T) {
0, 0,
}, },
{ {
"simple indirect expand", "simple back single relation field expand (deprecated syntax)",
"demo3", "demo3",
[]string{"lcl9d87w22ml6jy"}, []string{"lcl9d87w22ml6jy"},
[]string{"demo4(rel_one_no_cascade_required)"}, []string{"demo4(rel_one_no_cascade_required)"},
@@ -172,11 +174,22 @@ func TestExpandRecords(t *testing.T) {
0, 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", "demo3",
[]string{"lcl9d87w22ml6jy"}, []string{"lcl9d87w22ml6jy"},
[]string{ []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) { func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil) return app.Dao().FindRecordsByIds(c.Id, ids, nil)
@@ -184,6 +197,19 @@ func TestExpandRecords(t *testing.T) {
5, 5,
0, 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", "expand multiple relations sharing a common path",
"demo4", "demo4",
@@ -222,6 +248,8 @@ func TestExpandRecords(t *testing.T) {
} }
func TestExpandRecord(t *testing.T) { func TestExpandRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -328,7 +356,7 @@ func TestExpandRecord(t *testing.T) {
0, 0,
}, },
{ {
"simple indirect expand", "simple indirect expand via single relation field (deprecated syntax)",
"demo3", "demo3",
"lcl9d87w22ml6jy", "lcl9d87w22ml6jy",
[]string{"demo4(rel_one_no_cascade_required)"}, []string{"demo4(rel_one_no_cascade_required)"},
@@ -339,7 +367,18 @@ func TestExpandRecord(t *testing.T) {
0, 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", "demo3",
"lcl9d87w22ml6jy", "lcl9d87w22ml6jy",
[]string{ []string{
@@ -351,6 +390,19 @@ func TestExpandRecord(t *testing.T) {
5, 5,
0, 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 { for _, s := range scenarios {
@@ -372,6 +424,8 @@ func TestExpandRecord(t *testing.T) {
} }
func TestIndirectExpandSingeVsArrayResult(t *testing.T) { func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -382,21 +436,23 @@ func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
// non-unique indirect expand // 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) return app.Dao().FindRecordsByIds(c.Id, ids, nil)
}) })
if len(errs) > 0 { if len(errs) > 0 {
t.Fatal(errs) 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 { if !ok {
t.Fatalf("Expected the expanded result to be a slice, got %v", result) 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") demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -407,18 +463,16 @@ func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
if err := app.Dao().SaveCollection(demo4); err != nil { if err := app.Dao().SaveCollection(demo4); err != nil {
t.Fatalf("Failed to mock unique constraint: %v", err) t.Fatalf("Failed to mock unique constraint: %v", err)
} }
} // ---
// non-unique indirect expand errs := app.Dao().ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil) return app.Dao().FindRecordsByIds(c.Id, ids, nil)
}) })
if len(errs) > 0 { if len(errs) > 0 {
t.Fatal(errs) 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 { if !ok {
t.Fatalf("Expected the expanded result to be a single model, got %v", result) 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.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL" cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "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 // ensure that the new collection has an id
+5 -1
View File
@@ -14,6 +14,8 @@ import (
) )
func TestSyncRecordTableSchema(t *testing.T) { func TestSyncRecordTableSchema(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -81,7 +83,7 @@ func TestSyncRecordTableSchema(t *testing.T) {
[]string{ []string{
"id", "created", "updated", "test", "id", "created", "updated", "test",
"username", "email", "verified", "emailVisibility", "username", "email", "verified", "emailVisibility",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt", "tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt", "lastLoginAlertSentAt",
}, },
4, 4,
}, },
@@ -132,6 +134,8 @@ func TestSyncRecordTableSchema(t *testing.T) {
} }
func TestSingleVsMultipleValuesNormalization(t *testing.T) { func TestSingleVsMultipleValuesNormalization(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+53 -2
View File
@@ -19,6 +19,8 @@ import (
) )
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) { func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -58,6 +60,8 @@ func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
} }
func TestRecordQueryOneWithRecord(t *testing.T) { func TestRecordQueryOneWithRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -82,6 +86,8 @@ func TestRecordQueryOneWithRecord(t *testing.T) {
} }
func TestRecordQueryAllWithRecordsSlices(t *testing.T) { func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -143,6 +149,8 @@ func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
} }
func TestFindRecordById(t *testing.T) { func TestFindRecordById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -203,6 +211,8 @@ func TestFindRecordById(t *testing.T) {
} }
func TestFindRecordsByIds(t *testing.T) { func TestFindRecordsByIds(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -294,6 +304,8 @@ func TestFindRecordsByIds(t *testing.T) {
} }
func TestFindRecordsByExpr(t *testing.T) { func TestFindRecordsByExpr(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -364,6 +376,8 @@ func TestFindRecordsByExpr(t *testing.T) {
} }
func TestFindFirstRecordByData(t *testing.T) { func TestFindFirstRecordByData(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -427,6 +441,8 @@ func TestFindFirstRecordByData(t *testing.T) {
} }
func TestFindRecordsByFilter(t *testing.T) { func TestFindRecordsByFilter(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -530,6 +546,19 @@ func TestFindRecordsByFilter(t *testing.T) {
"llvuca81nly1qls", "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 { for _, s := range scenarios {
@@ -566,6 +595,8 @@ func TestFindRecordsByFilter(t *testing.T) {
} }
func TestFindFirstRecordByFilter(t *testing.T) { func TestFindFirstRecordByFilter(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -647,6 +678,8 @@ func TestFindFirstRecordByFilter(t *testing.T) {
} }
func TestCanAccessRecord(t *testing.T) { func TestCanAccessRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -814,6 +847,8 @@ func TestCanAccessRecord(t *testing.T) {
} }
func TestIsRecordValueUnique(t *testing.T) { func TestIsRecordValueUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -863,6 +898,8 @@ func TestIsRecordValueUnique(t *testing.T) {
} }
func TestFindAuthRecordByToken(t *testing.T) { func TestFindAuthRecordByToken(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -925,6 +962,8 @@ func TestFindAuthRecordByToken(t *testing.T) {
} }
func TestFindAuthRecordByEmail(t *testing.T) { func TestFindAuthRecordByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -956,6 +995,8 @@ func TestFindAuthRecordByEmail(t *testing.T) {
} }
func TestFindAuthRecordByUsername(t *testing.T) { func TestFindAuthRecordByUsername(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -988,6 +1029,8 @@ func TestFindAuthRecordByUsername(t *testing.T) {
} }
func TestSuggestUniqueAuthRecordUsername(t *testing.T) { func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1025,6 +1068,8 @@ func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
} }
func TestSaveRecord(t *testing.T) { func TestSaveRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1058,6 +1103,8 @@ func TestSaveRecord(t *testing.T) {
} }
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) { func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1090,6 +1137,8 @@ func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
} }
func TestDeleteRecord(t *testing.T) { func TestDeleteRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1156,17 +1205,19 @@ func TestDeleteRecord(t *testing.T) {
} }
// ensure that the json rel fields were prefixed // ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ") 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) { if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries) 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) { if !strings.Contains(joinedQueries, expectedRelOnePart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries) t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
} }
} }
func TestDeleteRecordBatchProcessing(t *testing.T) { func TestDeleteRecordBatchProcessing(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestSaveAndFindSettings(t *testing.T) { func TestSaveAndFindSettings(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+12
View File
@@ -12,6 +12,8 @@ import (
) )
func TestHasTable(t *testing.T) { func TestHasTable(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -36,6 +38,8 @@ func TestHasTable(t *testing.T) {
} }
func TestTableColumns(t *testing.T) { func TestTableColumns(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -64,6 +68,8 @@ func TestTableColumns(t *testing.T) {
} }
func TestTableInfo(t *testing.T) { func TestTableInfo(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestTableInfo(t *testing.T) {
} }
func TestDeleteTable(t *testing.T) { func TestDeleteTable(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -116,6 +124,8 @@ func TestDeleteTable(t *testing.T) {
} }
func TestVacuum(t *testing.T) { func TestVacuum(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -141,6 +151,8 @@ func TestVacuum(t *testing.T) {
} }
func TestTableIndexes(t *testing.T) { func TestTableIndexes(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+4 -4
View File
@@ -462,9 +462,9 @@ type identifiersParser struct {
func (p *identifiersParser) parse(selectQuery string) error { func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(strings.TrimSpace(selectQuery), ";") str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "") str = commentsReplaceRegex.ReplaceAllString(str, "")
str = joinReplaceRegex.ReplaceAllString(str, " __pb_join__ ")
str = discardReplaceRegex.ReplaceAllString(str, " __pb_discard__ ")
tk := tokenizer.NewFromString(str) tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t') tk.Separators(',', ' ', '\n', '\t')
@@ -499,7 +499,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
skip = false skip = false
partType = "from" partType = "from"
activeBuilder = &fromParts activeBuilder = &fromParts
case "_join_": case "__pb_join__":
skip = false skip = false
// the previous part was also a join // the previous part was also a join
@@ -509,7 +509,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
partType = "join" partType = "join"
activeBuilder = &joinParts activeBuilder = &joinParts
case "_discard_": case "__pb_discard__":
// skip following tokens // skip following tokens
skip = true skip = true
default: default:
+23 -3
View File
@@ -33,6 +33,8 @@ func ensureNoTempViews(app core.App, t *testing.T) {
} }
func TestDeleteView(t *testing.T) { func TestDeleteView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -60,6 +62,8 @@ func TestDeleteView(t *testing.T) {
} }
func TestSaveView(t *testing.T) { func TestSaveView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -180,6 +184,8 @@ func TestSaveView(t *testing.T) {
} }
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) { func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -196,6 +202,8 @@ func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
} }
func TestCreateViewSchema(t *testing.T) { func TestCreateViewSchema(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -240,11 +248,21 @@ func TestCreateViewSchema(t *testing.T) {
` `
select select
-- test single line -- test single line
id, demo1.id,
text, demo1.text,
/* multi /* multi
line comment */ line comment */
url, created, updated from demo1 demo1.url, demo1.created, demo2.updated from demo1
-- comment before join
join demo2 ON (
-- comment inside join
demo2.id = demo1.id
)
-- comment before where
where (
-- comment inside where
demo2.id = demo1.id
)
`, `,
false, false,
map[string]string{ map[string]string{
@@ -486,6 +504,8 @@ func TestCreateViewSchema(t *testing.T) {
} }
func TestFindRecordByViewFile(t *testing.T) { func TestFindRecordByViewFile(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+4
View File
@@ -10,6 +10,8 @@ import (
) )
func TestAdminLoginValidateAndSubmit(t *testing.T) { func TestAdminLoginValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -51,6 +53,8 @@ func TestAdminLoginValidateAndSubmit(t *testing.T) {
} }
func TestAdminLoginInterceptors(t *testing.T) { func TestAdminLoginInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -11,6 +11,8 @@ import (
) )
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) { func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -99,6 +101,8 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
} }
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) { func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -10,6 +10,8 @@ import (
) )
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) { func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -74,6 +76,8 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
} }
func TestAdminPasswordResetRequestInterceptors(t *testing.T) { func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+8
View File
@@ -12,6 +12,8 @@ import (
) )
func TestNewAdminUpsert(t *testing.T) { func TestNewAdminUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -31,6 +33,8 @@ func TestNewAdminUpsert(t *testing.T) {
} }
func TestAdminUpsertValidateAndSubmit(t *testing.T) { func TestAdminUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -183,6 +187,8 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
} }
func TestAdminUpsertSubmitInterceptors(t *testing.T) { func TestAdminUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -231,6 +237,8 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
} }
func TestAdminUpsertWithCustomId(t *testing.T) { func TestAdminUpsertWithCustomId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() 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-----`) 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 // Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct { type AppleClientSecretCreate struct {
@@ -33,7 +33,7 @@ type AppleClientSecretCreate struct {
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----. // Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"` 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). // The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"` Duration int `form:"duration" json:"duration"`
} }
+2
View File
@@ -15,6 +15,8 @@ import (
) )
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) { func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+2
View File
@@ -10,6 +10,8 @@ import (
) )
func TestBackupCreateValidateAndSubmit(t *testing.T) { func TestBackupCreateValidateAndSubmit(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
backupName string backupName string
+2
View File
@@ -12,6 +12,8 @@ import (
) )
func TestBackupUploadValidateAndSubmit(t *testing.T) { func TestBackupUploadValidateAndSubmit(t *testing.T) {
t.Parallel()
var zb bytes.Buffer var zb bytes.Buffer
zw := zip.NewWriter(&zb) zw := zip.NewWriter(&zb)
if err := zw.Close(); err != nil { if err := zw.Close(); err != nil {
+15
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -131,6 +132,7 @@ func (form *CollectionUpsert) Validate() error {
validation.Match(collectionNameRegex), validation.Match(collectionNameRegex),
validation.By(form.ensureNoSystemNameChange), validation.By(form.ensureNoSystemNameChange),
validation.By(form.checkUniqueName), validation.By(form.checkUniqueName),
validation.By(form.checkForVia),
), ),
// validates using the type's own validation rules + some collection's specifics // validates using the type's own validation rules + some collection's specifics
validation.Field( 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 { func (form *CollectionUpsert) checkUniqueName(value any) error {
v, _ := value.(string) v, _ := value.(string)
+19
View File
@@ -16,6 +16,8 @@ import (
) )
func TestNewCollectionUpsert(t *testing.T) { func TestNewCollectionUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -88,6 +90,8 @@ func TestNewCollectionUpsert(t *testing.T) {
} }
func TestCollectionUpsertValidateAndSubmit(t *testing.T) { func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -101,6 +105,17 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}}, {"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
{"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}}, {"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
{"empty update", "demo2", "{}", []string{}}, {"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", "create failure",
"", "",
@@ -654,6 +669,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
} }
func TestCollectionUpsertSubmitInterceptors(t *testing.T) { func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -704,6 +721,8 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
} }
func TestCollectionUpsertWithCustomId(t *testing.T) { func TestCollectionUpsertWithCustomId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6
View File
@@ -11,6 +11,8 @@ import (
) )
func TestCollectionsImportValidate(t *testing.T) { func TestCollectionsImportValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -38,6 +40,8 @@ func TestCollectionsImportValidate(t *testing.T) {
} }
func TestCollectionsImportSubmit(t *testing.T) { func TestCollectionsImportSubmit(t *testing.T) {
t.Parallel()
totalCollections := 11 totalCollections := 11
scenarios := []struct { scenarios := []struct {
@@ -461,6 +465,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
} }
func TestCollectionsImportSubmitInterceptors(t *testing.T) { func TestCollectionsImportSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+4
View File
@@ -19,5 +19,9 @@ func NewRealtimeSubscribe() *RealtimeSubscribe {
func (form *RealtimeSubscribe) Validate() error { func (form *RealtimeSubscribe) Validate() error {
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)), 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 package forms_test
import ( import (
"encoding/json"
"fmt"
"strings" "strings"
"testing" "testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
) )
func TestRealtimeSubscribeValidate(t *testing.T) { func TestRealtimeSubscribeValidate(t *testing.T) {
scenarios := []struct { t.Parallel()
clientId string
expectError bool validSubscriptionsLimit := make([]string, 1000)
}{ for i := 0; i < len(validSubscriptionsLimit); i++ {
{"", true}, validSubscriptionsLimit[i] = fmt.Sprintf(`"%d"`, i)
{strings.Repeat("a", 256), true}, }
{"test", false}, invalidSubscriptionsLimit := make([]string, 1001)
for i := 0; i < len(invalidSubscriptionsLimit); i++ {
invalidSubscriptionsLimit[i] = fmt.Sprintf(`"%d"`, i)
} }
for i, s := range scenarios { scenarios := []struct {
form := forms.NewRealtimeSubscribe() name string
form.ClientId = s.clientId 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 err := json.Unmarshal([]byte(s.data), &form)
if hasErr != s.expectError { if err != nil {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) 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) { func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -145,6 +147,8 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
} }
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) { func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -12,6 +12,8 @@ import (
) )
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) { func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -106,6 +108,8 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
} }
func TestRecordEmailChangeRequestInterceptors(t *testing.T) { func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+43 -9
View File
@@ -129,6 +129,10 @@ func (form *RecordOAuth2Login) Submit(
return nil, nil, err 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -222,15 +226,11 @@ func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
// load custom data // load custom data
createForm.LoadData(form.CreateData) createForm.LoadData(form.CreateData)
// load the OAuth2 profile data as fallback // load the OAuth2 user data
if createForm.Email == "" { createForm.Email = data.OAuth2User.Email
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)
}
createForm.Verified = false // generate a random password if not explicitly set
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
}
if createForm.Password == "" { if createForm.Password == "" {
createForm.Password = security.RandomString(30) createForm.Password = security.RandomString(30)
createForm.PasswordConfirm = createForm.Password createForm.PasswordConfirm = createForm.Password
@@ -247,6 +247,40 @@ func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
return err return err
} }
} else { } 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
}
}
// prevent pre-hijacking with different OAuth2 provider
//
// delete all other previous OAuth2 record links for the cases
// when the user was precreated by malicious OAuth2 auth with custom payload data
//
// while this would be also done automatically on unverified -> verified upgrade,
// doing it manually here ensures that a single unverified record could have
// max 1 OAuth2 link to prevent further abuse when mixed with other auth flows
if !data.Record.Verified() {
externalAuths, err := txDao.FindAllExternalAuthsByRecord(data.Record)
if err != nil {
return err
}
for _, ea := range externalAuths {
if err := txDao.DeleteExternalAuth(ea); err != nil {
return err
}
}
data.ExternalAuth = nil // clear to allow recreate below
}
// update the existing auth record empty email if the data.OAuth2User has one // update the existing auth record empty email if the data.OAuth2User has one
// (this is in case previously the auth record was created // (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address) // with an OAuth2 provider that didn't return an email address)
+2
View File
@@ -10,6 +10,8 @@ import (
) )
func TestUserOauth2LoginValidate(t *testing.T) { func TestUserOauth2LoginValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+4
View File
@@ -10,6 +10,8 @@ import (
) )
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) { func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -132,6 +134,8 @@ func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
} }
func TestRecordPasswordLoginInterceptors(t *testing.T) { func TestRecordPasswordLoginInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+15 -1
View File
@@ -6,6 +6,8 @@ import (
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators" "github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
) )
// RecordPasswordResetConfirm is an auth record password reset confirmation form. // RecordPasswordResetConfirm is an auth record password reset confirmation form.
@@ -91,9 +93,21 @@ func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*
return nil, err 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 { interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m authRecord = m
return form.dao.SaveRecord(m) return form.dao.SaveRecord(authRecord)
}, interceptors...) }, interceptors...)
if interceptorsErr != nil { if interceptorsErr != nil {
@@ -13,6 +13,8 @@ import (
) )
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) { func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -136,6 +138,8 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
} }
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) { func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() 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. // 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 { func (form *RecordPasswordResetRequest) Validate() error {
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
validation.Field( validation.Field(
@@ -13,6 +13,8 @@ import (
) )
func TestRecordPasswordResetRequestSubmit(t *testing.T) { func TestRecordPasswordResetRequestSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -116,6 +118,8 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
} }
func TestRecordPasswordResetRequestInterceptors(t *testing.T) { func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+50 -22
View File
@@ -165,7 +165,7 @@ func (form *RecordUpsert) extractMultipartFormData(
data := map[string]any{} data := map[string]any{}
filesToUpload := map[string][]*filesystem.File{} filesToUpload := map[string][]*filesystem.File{}
arrayValueSupportTypes := schema.ArraybleFieldTypes() arraybleFieldTypes := schema.ArraybleFieldTypes()
for fullKey, values := range r.PostForm { for fullKey, values := range r.PostForm {
key := fullKey key := fullKey
@@ -178,8 +178,18 @@ func (form *RecordUpsert) extractMultipartFormData(
continue 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) 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 data[key] = values
} else { } else {
data[key] = values[0] data[key] = values[0]
@@ -313,10 +323,10 @@ func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error
// Example // Example
// //
// // mark only only 2 files for removal // // 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 // // mark all "documents" files for removal
// form.AddFiles("documents") // form.RemoveFiles("documents")
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error { func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
field := form.record.Collection().Schema.GetFieldByName(key) field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile { if field == nil || field.Type != schema.FieldTypeFile {
@@ -746,6 +756,8 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
dao := form.dao.Clone() dao := form.dao.Clone()
var uploaded []string
// upload new files (if any) // upload new files (if any)
// //
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions // 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 { dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error { newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() { 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 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 { dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error { newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() { 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 return err
} }
} }
@@ -791,6 +807,9 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
// persist the record model // persist the record model
if err := dao.SaveRecord(form.record); err != nil { if err := dao.SaveRecord(form.record); err != nil {
// cleanup - try to delete only the successfully uploaded files
form.deleteFilesByNamesList(uploaded)
return form.prepareError(err) return form.prepareError(err)
} }
@@ -809,30 +828,30 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
}, interceptors...) }, interceptors...)
} }
func (form *RecordUpsert) processFilesToUpload() error { func (form *RecordUpsert) processFilesToUpload() ([]string, error) {
if len(form.filesToUpload) == 0 { if len(form.filesToUpload) == 0 {
return nil // no parsed file fields return nil, nil // no parsed file fields
} }
if !form.record.HasId() { 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 { if err != nil {
return err return nil, err
} }
defer fs.Close() defer fsys.Close()
var uploadErrors []error // list of upload errors 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 fieldKey := range form.filesToUpload {
for i, file := range form.filesToUpload[fieldKey] { for i, file := range form.filesToUpload[fieldKey] {
path := form.record.BaseFilesPath() + "/" + file.Name 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 // keep track of the already uploaded file
uploaded = append(uploaded, path) uploaded = append(uploaded, file.Name)
} else { } else {
// store the upload error // store the upload error
uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err)) 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) // cleanup - try to delete the successfully uploaded files (if any)
form.deleteFilesByNamesList(uploaded) 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) { 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") 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 { if err != nil {
return filenames, err return filenames, err
} }
defer fs.Close() defer fsys.Close()
var deleteErrors []error var deleteErrors []error
for i := len(filenames) - 1; i >= 0; i-- { for i := len(filenames) - 1; i >= 0; i-- {
filename := filenames[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 // remove the deleted file from the list
filenames = append(filenames[:i], filenames[i+1:]...) filenames = append(filenames[:i], filenames[i+1:]...)
// try to delete the related file thumbs (if any) // try to delete the related file thumbs (if any)
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/") fsys.DeletePrefix(baseDir + "/thumbs_" + filename + "/")
} else { } else {
// store the delete error // store the delete error
deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err)) 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) 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 return filenames, nil
} }
+106 -3
View File
@@ -22,6 +22,7 @@ import (
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
) )
@@ -150,9 +151,10 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
} }
formData, mp, err := tests.MockMultipartData(map[string]string{ formData, mp, err := tests.MockMultipartData(map[string]string{
"a.b.id": "test_id", "a.b.id": "test_id",
"a.b.text": "test123", "a.b.text": "test123",
"a.b.unknown": "test456", "a.b.unknown": "test456",
"a.b." + rest.MultipartJsonKey: `{"json":["a","b"],"email":"test3@example.com"}`,
// file fields unset/delete // file fields unset/delete
"a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier "a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier
"a.b.file_many.0": "", // delete by index "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) 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"] fileOne, ok := form.Data()["file_one"]
if !ok { if !ok {
t.Fatal("Expect file_one field to be set") 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) { func TestRecordUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -12,6 +12,8 @@ import (
) )
func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) { func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -98,6 +100,8 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
} }
func TestRecordVerificationConfirmInterceptors(t *testing.T) { func TestRecordVerificationConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -13,6 +13,8 @@ import (
) )
func TestRecordVerificationRequestSubmit(t *testing.T) { func TestRecordVerificationRequestSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -134,6 +136,8 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
} }
func TestRecordVerificationRequestInterceptors(t *testing.T) { func TestRecordVerificationRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+6
View File
@@ -14,6 +14,8 @@ import (
) )
func TestNewSettingsUpsert(t *testing.T) { func TestNewSettingsUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -30,6 +32,8 @@ func TestNewSettingsUpsert(t *testing.T) {
} }
func TestSettingsUpsertValidateAndSubmit(t *testing.T) { func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -127,6 +131,8 @@ func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
} }
func TestSettingsUpsertSubmitInterceptors(t *testing.T) { func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+2
View File
@@ -10,6 +10,8 @@ import (
) )
func TestEmailSendValidateAndSubmit(t *testing.T) { func TestEmailSendValidateAndSubmit(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
template string template string
email string email string
+4
View File
@@ -9,6 +9,8 @@ import (
) )
func TestS3FilesystemValidate(t *testing.T) { func TestS3FilesystemValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -66,6 +68,8 @@ func TestS3FilesystemValidate(t *testing.T) {
} }
func TestS3FilesystemSubmitFailure(t *testing.T) { func TestS3FilesystemSubmitFailure(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+4
View File
@@ -12,6 +12,8 @@ import (
) )
func TestUploadedFileSize(t *testing.T) { func TestUploadedFileSize(t *testing.T) {
t.Parallel()
data, mp, err := tests.MockMultipartData(nil, "test") data, mp, err := tests.MockMultipartData(nil, "test")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -52,6 +54,8 @@ func TestUploadedFileSize(t *testing.T) {
} }
func TestUploadedFileMimeType(t *testing.T) { func TestUploadedFileMimeType(t *testing.T) {
t.Parallel()
data, mp, err := tests.MockMultipartData(nil, "test") data, mp, err := tests.MockMultipartData(nil, "test")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
) )
// Compare checks whether the provided model id exists. // UniqueId checks whether the provided model id already exists.
// //
// Example: // Example:
// //
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestUniqueId(t *testing.T) { func TestUniqueId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+5
View File
@@ -2,6 +2,7 @@ package validators
import ( import (
"fmt" "fmt"
"math"
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
@@ -159,6 +160,10 @@ func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField
return nil // nothing to check (skip zero-defaults) 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) options, _ := field.Options.(*schema.NumberOptions)
if options.NoDecimal && val != float64(int64(val)) { if options.NoDecimal && val != float64(int64(val)) {
+12
View File
@@ -45,6 +45,8 @@ func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) {
} }
func TestRecordDataValidatorValidateText(t *testing.T) { func TestRecordDataValidatorValidateText(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -246,6 +248,16 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
nil, nil,
[]string{"field2"}, []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", "(number) check min constraint",
map[string]any{ map[string]any{
+2
View File
@@ -7,6 +7,8 @@ import (
) )
func TestCompare(t *testing.T) { func TestCompare(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
valA string valA string
valB string valB string
+60 -70
View File
@@ -1,96 +1,86 @@
module github.com/pocketbase/pocketbase module github.com/pocketbase/pocketbase
go 1.21 go 1.25.0
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 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.6
github.com/aws/aws-sdk-go-v2/config v1.28.6
github.com/aws/aws-sdk-go-v2/credentials v1.17.47
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0
github.com/aws/smithy-go v1.22.1
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2 github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
github.com/fatih/color v1.16.0 github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3 github.com/gabriel-vasile/mimetype v1.4.7
github.com/ganigeorgiev/fexpr v0.4.0 github.com/ganigeorgiev/fexpr v0.4.1
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 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.4
github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-jwt/jwt/v4 v4.5.2
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 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.24
github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4 github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75
github.com/spf13/cast v1.6.0 github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.1
gocloud.dev v0.35.0 gocloud.dev v0.40.0
golang.org/x/crypto v0.16.0 golang.org/x/crypto v0.52.0
golang.org/x/net v0.19.0 golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.15.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.5.0 golang.org/x/sync v0.20.0
modernc.org/sqlite v1.27.0 modernc.org/sqlite v1.48.2
) )
require ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 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.6.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
github.com/aws/aws-sdk-go-v2/config v1.26.1 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect github.com/dlclark/regexp2 v1.11.4 // 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/dustin/go-humanize v1.0.1 // 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/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/google/uuid v1.4.0 // indirect github.com/googleapis/gax-go/v2 v2.14.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.14.0 // indirect golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.14.0 // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.15.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.15.0 // indirect golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.16.0 // indirect golang.org/x/tools v0.45.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.153.0 // indirect google.golang.org/api v0.213.0 // indirect
google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect google.golang.org/grpc v1.69.2 // indirect
google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.36.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect modernc.org/libc v1.70.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/cc/v3 v3.41.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.37.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
) )
+191 -202
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.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.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic= cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w= cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= 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 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= 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/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 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 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-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 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 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.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.48.16/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
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.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
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/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
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.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
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.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7/go.mod h1:tDVvl8hyU6E9B8TrnNrZQEVkQlB8hjJwcgpPhgtlnNg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
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.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
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.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
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.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
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/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
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.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
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.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
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/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
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.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
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.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
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.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
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.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
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.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
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.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
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.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 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/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/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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -72,50 +74,51 @@ 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/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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 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.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/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/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= 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/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-20241009100908-5f46f2705ca3 h1:MXsAuToxwsTn5BEEYm2DheqIiC4jWGmkEJ1uy+KFhvQ=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw= github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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.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.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/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/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.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 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.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/ganigeorgiev/fexpr v0.4.0 h1:ojitI+VMNZX/odeNL1x3RzTTE8qAIVvnSSYPNAnQFDI= github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= 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 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= 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.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 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.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.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -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.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 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.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 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.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.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.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.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.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
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/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.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.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= 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.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
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 h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 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 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.24/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-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 h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4 h1:85kAYIKrKEeau7WgXg8B7Km8etrVavJAyH7XcR5MkFw= github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75 h1:XSbmekxgmbI2uPrre/nkCz7y8VsV652TPb3hAYzPb74=
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/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/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.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -231,23 +218,37 @@ 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= 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 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
gocloud.dev v0.35.0 h1:x/Gtt5OJdT4j+ir1AXAIXb7bBnFawXAAaJptCUGk3HU= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
gocloud.dev v0.35.0/go.mod h1:wbyF+BhfdtLWyUtVEWRW13hFLb1vXnV2ovEhYGQe3ck= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.40.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-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-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.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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -258,17 +259,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-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-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.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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-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-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.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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -276,65 +277,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-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-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-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-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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.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.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.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.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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.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-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-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-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-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-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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-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-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.153.0 h1:N1AwGhielyKFaUqH07/ZSIQR3uNPcV7NVw0vj+j4iR4= google.golang.org/api v0.213.0 h1:KmF6KaDyFqB417T68tMPbVmmwtIXs2VB60OJKIHB0xQ=
google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY= google.golang.org/api v0.213.0/go.mod h1:V0T5ZhNUUNpYAlL306gFZPFt5F5D/IeyLoktduYYnvQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 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.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.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-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-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-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-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY= google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY= google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI= google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
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-20241216192217-9240e9c98484 h1:Z7FRVJPSMaHQxD0uXU8WdgFh8PseLM8Q8NzhnpMrBhQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 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.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 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.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 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-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -344,16 +339,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.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.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.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.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -361,31 +350,31 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/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= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/libc v1.37.0 h1:WerjebcsP6A7Jy+f2lCnHAkiSTLf7IaSftBYUtoswak= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/libc v1.37.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 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) { func TestSendAdminPasswordReset(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+38 -1
View File
@@ -12,6 +12,43 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer" "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. // SendRecordPasswordReset sends a password reset request email to the specified user.
func SendRecordPasswordReset(app core.App, authRecord *models.Record) error { func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
token, tokenErr := tokens.NewRecordResetPasswordToken(app, authRecord) 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 { func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string) error {
token, tokenErr := tokens.NewRecordChangeEmailToken(app, record, newEmail) token, tokenErr := tokens.NewRecordChangeEmailToken(app, record, newEmail)
if tokenErr != nil { if tokenErr != nil {
+35
View File
@@ -8,7 +8,38 @@ import (
"github.com/pocketbase/pocketbase/tests" "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) { func TestSendRecordPasswordReset(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -37,6 +68,8 @@ func TestSendRecordPasswordReset(t *testing.T) {
} }
func TestSendRecordVerification(t *testing.T) { func TestSendRecordVerification(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -62,6 +95,8 @@ func TestSendRecordVerification(t *testing.T) {
} }
func TestSendRecordChangeEmail(t *testing.T) { func TestSendRecordChangeEmail(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+1 -1
View File
@@ -53,7 +53,7 @@ const Layout = `
.btn { .btn {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
border: 0; border: 1px solid #e5e5e5;
cursor: pointer; cursor: pointer;
color: #fff !important; color: #fff !important;
background: #16161a !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}}
`
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff. Show more