Compare commits

...
240 Commits
Author SHA1 Message Date
Gani Georgiev 98c8c98603 updated jsvm types 2023-12-10 12:50:56 +02:00
Gani Georgiev 97345f0317 skip log writes if max retention setting is zero 2023-12-10 12:40:33 +02:00
Gani Georgiev b29e404f22 updated ui/dist, go deps, docs and fixed some typos 2023-12-10 12:23:31 +02:00
Gani Georgiev d8ec36fa4c updated jsvm types 2023-12-09 22:40:45 +02:00
Gani Georgiev fb2eafe860 [#3790] added MaxSize json field option 2023-12-09 22:30:37 +02:00
Gani Georgiev b9f391cf85 revert ResetBootstrapState removal on app termination since closing the db explicitly enforces checkout and clearing the side-car wal file 2023-12-09 19:43:29 +02:00
Gani Georgiev 646f90ef43 updated logs chart 2023-12-09 16:31:17 +02:00
Gani Georgiev 5b6b4599b7 updated logs listing 2023-12-09 15:12:00 +02:00
Gani Georgiev 35fc6d0734 define Server.BaseContext to cancel globally the SSE connections on server shutdown 2023-12-08 23:14:14 +02:00
Gani Georgiev 506b759560 fixed graceful shutdown handling 2023-12-08 21:16:48 +02:00
Gani Georgiev d86e20b7f2 remove the unnecessary App.ResetBootstrapState calls as sqlite connections will be closed anyway with the process termination 2023-12-08 19:24:14 +02:00
Gani Georgiev 4c473385b2 trigger OnTerminate() hook on app.Restart() call 2023-12-08 15:46:33 +02:00
Gani Georgiev afbbc1d97c removed unnecessary logs index and updated logs ui 2023-12-08 14:26:06 +02:00
Gani Georgiev 4d3ba270c0 fix nullable non-equal comparisions 2023-12-08 13:50:12 +02:00
Gani Georgiev 1bf7f148b0 minor types.DateTime optimizations to minimize time.Time value copies 2023-12-08 10:36:12 +02:00
Gani Georgiev 6e6c873cc6 [#3896] added $apis.requireGuestOnly() middleware JSVM binding 2023-12-07 18:49:56 +02:00
Gani Georgiev 16da7d9e1a removed unused options struct 2023-12-06 20:44:47 +02:00
Gani Georgiev f7df737c45 added filesystem.NewFileFromUrl(ctx, url) 2023-12-06 20:42:30 +02:00
Gani Georgiev 64eefb44e8 added onlyVerified field to the authMethods response 2023-12-06 13:30:47 +02:00
Gani Georgiev 31317df21c added onlyVerified auth collection option 2023-12-06 11:57:04 +02:00
Gani Georgiev 865865fdeb updated jsvm $security.parse* token helpers to return the payload as plain object 2023-12-04 20:46:33 +02:00
Gani Georgiev 5b2575b754 [#3877] fixed test messages typo 2023-12-04 18:09:29 +02:00
Gani Georgiev 6327ac20da updated changelog 2023-12-04 17:18:57 +02:00
Gani GeorgievandTobias Muehlberger 8cd1c8709c [#3794] limit concurrent thumbs generation
Co-authored-by: Tobias Muehlberger <tobias@muehlberger.dev>
2023-12-04 16:52:10 +02:00
Gani Georgiev 14a2fd6215 skip wrapping sql.ErrNoRows 2023-12-04 16:23:56 +02:00
Gani Georgiev cdfc1f7b70 removed unnecessary Close call and formatted map hints 2023-12-04 16:22:49 +02:00
Gani Georgiev 41dcd9b4d4 use error.Is to handle wrapped errors 2023-12-04 16:21:57 +02:00
Gani Georgiev 0fb859c321 updated logs list min-width 2023-12-03 20:58:12 +02:00
Gani Georgiev f57d38f529 use linear thumb resample filter 2023-12-03 20:56:28 +02:00
Gani Georgiev 04024cb6b7 removed incorrect base error message 2023-12-03 20:55:15 +02:00
Gani Georgiev 58a2d3cd09 added the failed dao query to the error message 2023-12-03 20:54:48 +02:00
Gani Georgiev 4d27278c60 always show list errors if there is no filter 2023-12-03 20:54:04 +02:00
Gani Georgiev 70f1647a4c updated logs list styles 2023-12-03 14:12:44 +02:00
Gani Georgiev 5b94aced3a use a red colored stderr writer for the cobra cmd errors 2023-12-03 13:44:30 +02:00
Gani Georgiev 070a1cd6d9 removed eagerly resetting the bootstrap state to prevent concurrent access errors 2023-12-03 12:36:51 +02:00
Gani Georgiev 716f508d66 removed activity logger for the realtime connect action and added helper debug log when subscriptions are changed 2023-12-03 12:12:30 +02:00
Gani Georgiev 7013174315 removed empty local() font-face declarations 2023-12-03 12:00:11 +02:00
Gani Georgiev 559aad36a3 added the log id in the query params 2023-12-03 11:39:40 +02:00
Gani Georgiev 6416328c3b added support for specifying @collection.* aliases 2023-12-03 10:57:58 +02:00
Gani Georgiev d3713a9d7c added support for comments in the API rules and filter expressions 2023-12-02 16:37:04 +02:00
Gani Georgiev aaab643629 [#3700] allow a single OAuth2 user to be used for authentication in multiple auth collection 2023-12-02 12:43:22 +02:00
Gani Georgiev b283ee2263 added OAuth2 displayName and pkce options 2023-11-29 20:19:54 +02:00
Gani Georgiev 995733000f added filesystem.Copy(src, dest) 2023-11-28 21:09:53 +02:00
Gani Georgiev 99bdb4e701 [#3617] added expiry field to the OAuth2 user 2023-11-27 20:32:28 +02:00
Gani Georgiev 3b79535dc7 sort the auth providers by their Name field 2023-11-27 20:05:06 +02:00
Gani Georgiev 05cc3f9e6c updated confirm password reset docs example 2023-11-26 15:00:48 +02:00
Gani Georgiev 3f2e38ca82 updated API preview examples 2023-11-26 14:59:14 +02:00
Gani Georgiev 531a7abec9 updated links formatting in the autogenerated html->text mail body 2023-11-26 14:47:26 +02:00
Gani Georgiev 821aae4a62 logs refactoring 2023-11-26 13:33:17 +02:00
Gani Georgiev ff5535f4de synced with master 2023-11-11 12:51:26 +02:00
Gani Georgiev 985ab1e5b7 updated changelog 2023-11-11 12:50:39 +02:00
Gani Georgiev 69a805d0d1 synced with master 2023-11-11 12:50:20 +02:00
Gani Georgiev d240649497 updated ui/dist 2023-11-11 12:48:11 +02:00
Gani Georgiev 9957919d9a updated tygoja and the generated jsvm types 2023-11-11 12:46:46 +02:00
Gani Georgiev 890a0904cf [#3697] allowed hyphens in usernames 2023-11-11 12:19:33 +02:00
Gani Georgiev cdd32512d5 synced with master 2023-11-10 15:18:14 +02:00
Gani Georgiev 5835193900 [#3735] fixed text field min/max validators to properly count multi-byte characters 2023-11-10 14:58:00 +02:00
Gani Georgiev 4abe199acc [#3715] fixed TinyMCE source code viewer textarea styles 2023-11-08 21:19:16 +02:00
Gani Georgiev a170923637 synced with master 2023-11-06 11:42:59 +02:00
Gani Georgiev f4f3724b7a updated ui/dist 2023-11-06 11:35:59 +02:00
Gani Georgievandsergeypdev ba7cf8bf8e [#3689] relaxed the OAuth2 redirect url validation to allow any string value
Co-authored-by: sergeypdev <sergeypoznyak@protonmail.com>
2023-11-06 11:33:10 +02:00
Gani Georgiev 500615c1ee added missing documention for the JSVM $mails.* bindings 2023-11-06 11:26:38 +02:00
Gani Georgiev 8961232a44 [#3685] added the release notes to the success ghupdate output 2023-11-06 11:19:12 +02:00
Gani Georgiev 907167e696 synced with master 2023-11-03 09:56:50 +02:00
Gani Georgiev 4e51e393a2 updated ui/dist 2023-11-03 05:50:49 +02:00
Gani Georgiev 5ea784609f Merge branch 'master' into develop 2023-10-28 18:46:43 +03:00
Gani Georgiev ea5ca009de [#3627] updated tygoja to fallback to []number for the generated TS []byte union type when used in 'M extends T' declarations 2023-10-28 16:38:18 +03:00
Gani Georgiev d13802133a fixed changelog typos 2023-10-28 00:27:59 +03:00
Gani Georgiev f3a40001a4 updated codemirror deps and regenerated ui/dist 2023-10-27 22:40:18 +03:00
Gani Georgiev 1ae570921b added negative string number normalizations for the json field type 2023-10-27 22:37:11 +03:00
Gani Georgiev f889a3fcb3 synced with master 2023-10-27 22:28:15 +03:00
Gani Georgiev 34fed679fd removed old comment 2023-10-27 17:38:53 +03:00
Gani Georgiev b7a49efa88 fixed excerpt modifier to properly add spaces after block tags 2023-10-27 17:36:26 +03:00
Gani Georgiev 01e8c0f9f7 [#3616] fixed tokenizer whitespace characters trimming 2023-10-27 15:19:06 +03:00
Gani Georgiev 1d67a35acf added changelog rc note 2023-10-27 07:29:26 +03:00
Gani Georgiev e2d8028d0a [#3602] use the auth collection name in the OAuth2 examples 2023-10-25 22:18:18 +03:00
Gani Georgiev d8a1875f84 fix the node version as latest seems to cause some issue with sass 2023-10-24 15:12:19 +03:00
Gani Georgiev 79617e6d99 =added experimental expand, filter, fields, custom query and headers parameters support for the realtime subscriptions 2023-10-24 14:46:03 +03:00
Gani Georgiev e6f1b3dfe4 updated relation field validation message 2023-10-21 15:52:19 +03:00
Gani Georgiev 94253f0dd5 updated the supported non-cgo build targets list 2023-10-16 20:27:37 +03:00
Kunal Singh 6cfaf343ac [#3531] updated README.md and CONTRIBUTING.md formatting 2023-10-16 20:18:05 +03:00
Gani Georgiev 9c562294ff set a default id column width and updated ui dist 2023-10-15 14:40:16 +03:00
Gani Georgiev 3c5409d607 updated changelog 2023-10-15 14:17:09 +03:00
Gani Georgiev 8868fa9ae6 use a custom tinymce svelte component and other minor optimizations 2023-10-15 14:04:44 +03:00
Gani Georgiev c0fa53a2ab check the mime type of the collections file field and updated field styles to minimize the layout shifts 2023-10-15 06:49:32 +03:00
Gani Georgiev 007b6a04ff updated dependencies and regenerated jsvm types 2023-10-14 23:28:32 +03:00
Gani Georgiev 731383a915 added .cmd() as alias for .exec() 2023-10-14 20:08:21 +03:00
Gani Georgiev 866d38caf9 updated jsvm types and removed unused helper 2023-10-14 19:14:27 +03:00
Gani Georgiev 1f6ab24b34 updated replaceQueryParams to use the last ? 2023-10-14 15:11:32 +03:00
Gani Georgievandthisni1s 01e33c07fe [#3364] added mailcow OAuth2 provider
Co-authored-by: thisni1s <nils@jn2p.de>
2023-10-14 14:52:35 +03:00
Gani Georgiev 69983bff5e removed legacy fonts 2023-10-12 23:34:34 +03:00
Gani Georgiev 2567659696 dragline z-index fix 2023-10-10 21:38:15 +03:00
Gani Georgiev 3e487f7e9d updated api preview docs 2023-10-09 21:03:02 +03:00
Gani Georgiev ca1a395628 minor styles adjustments 2023-10-09 19:55:53 +03:00
Gani Georgiev 1a47c70ccf Added support to manually resize the collections sidebar 2023-10-09 16:11:49 +03:00
Gani Georgiev 1f4bdfb867 [#3112] added options to pin collections 2023-10-09 14:26:56 +03:00
Gani Georgiev eae16cc42c synced with master 2023-10-09 12:01:21 +03:00
Gani Georgiev 1527b5ea4f updated CHANGELOG 2023-10-08 23:43:58 +03:00
Gani Georgiev ba6e17b3be updated jsvm types 2023-10-08 23:26:23 +03:00
Gani Georgiev b8219af941 [#3476] added raw template function 2023-10-08 23:17:38 +03:00
Gani Georgiev 8865cc1431 renamed record upsert local requestInfo to requestData to distinguish better from models.RequestInfo 2023-10-08 22:52:14 +03:00
Gani Georgiev 20b6ce4b84 excluded expand from the record draft and applied some lint fields alignment suggestions 2023-10-08 15:22:03 +03:00
Gani Georgiev e2f806d8bb added jsvm subscriptions.Message binding 2023-10-07 16:11:38 +03:00
Gani Georgiev 49e3f4ad93 [#3447] added jsvm http.Cookie binding 2023-10-07 15:35:20 +03:00
Gani Georgiev 6d672348e7 rearanged the DefaultClient struct fields to reduce its size from ~72 to ~32 bytes 2023-10-07 13:17:32 +03:00
Gani Georgiev 80d774a8ef [#3461] removed content-type charset and deprecated keep-alive header field 2023-10-07 12:57:07 +03:00
Gani Georgiev 5a5125383a Merge branch 'master' into develop 2023-10-05 09:33:46 +03:00
Gani Georgiev 7fa1ff53c9 trim view query semicolon chars and allow single quotes for column aliases 2023-10-05 09:31:24 +03:00
Gani Georgiev 0f4e27a11f updated nonempty label styles 2023-10-04 10:19:00 +03:00
Gani Georgiev 9997223923 fixed comment 2023-10-04 01:27:50 +03:00
Gani Georgiev 632ade795f updated file picker thumbs size 2023-10-03 16:18:32 +03:00
Gani Georgiev 91bd739b71 load only records with non-empty file fields and fupdated files list styles 2023-10-03 15:00:45 +03:00
Gani Georgiev 957064d70b extract the thumb sizes only from the selected file field 2023-10-03 12:51:55 +03:00
Gani Georgiev 609792a355 added records file picker support for the editor field 2023-10-03 10:36:46 +03:00
Gani Georgiev 2f5cfcfe87 replaced interface{} with any 2023-10-01 18:45:27 +03:00
Gani Georgiev 5732bc38e3 reload the records counter and remove drafts failures from LocalStorage 2023-10-01 15:57:20 +03:00
Gani Georgiev d69181cfef added helper class to disable the tabs animation to avoid the flickering 2023-10-01 15:56:29 +03:00
Gani Georgiev 8908d03b8c added support for linking to the record preview/update form and some other minor improvements 2023-10-01 12:55:30 +03:00
Gani Georgiev ebf73f5602 updated ui/dist 2023-09-30 14:43:12 +03:00
Gani Georgiev 8416f03bcf show local date on hover 2023-09-30 12:13:00 +03:00
Gani Georgiev 5d87385170 synced with master 2023-09-30 10:20:57 +03:00
Gani Georgiev 837134559f updated changelog 2023-09-30 09:37:26 +03:00
Gani Georgiev fadd12cd22 update tygoja and the generated jsvm typings 2023-09-28 23:04:34 +03:00
Gani Georgiev 469769d270 updated go deps 2023-09-25 23:30:10 +03:00
Gani Georgiev e4b7303a5d synced with master 2023-09-25 23:26:07 +03:00
Gani Georgiev e1fb5d26a5 [#3382] replaced filepath with path when extracting the filekey parent prefix 2023-09-25 22:47:48 +03:00
Gani Georgiev 4f396ca439 synced with master 2023-09-24 11:57:45 +03:00
Gani Georgiev ff08fc0fa4 remove the created and updated fields from the view API Preview and listings if the query doesn't have them 2023-09-24 11:27:10 +03:00
Gani Georgiev 4b511475ff updated go deps 2023-09-24 11:07:35 +03:00
Gani Georgiev 2550a9de54 [#3344, #2505] optimized records listing 2023-09-24 11:05:12 +03:00
Gani Georgiev 0f5dad7ede synced with master 2023-09-22 21:24:46 +03:00
Gani Georgiev d0b1c9d998 updated the invalid rel ids reactivity handling 2023-09-22 18:40:00 +03:00
Gani Georgiev fd9e120434 updated go deps and regenerated jsvm types 2023-09-22 18:24:34 +03:00
Gani Georgiev 92731ddd50 [#3372] fixed Admin UI listing error on invalid record relation 2023-09-22 18:19:05 +03:00
Gani Georgiev 4b4aaf2112 use goccy/go-json to speedup serialization 2023-09-18 22:52:36 +03:00
Gani Georgiev 6013d14bc6 added support for :excerpt(max, withEllipsis?) fields modifier 2023-09-18 15:20:10 +03:00
Gani Georgiev f3bcd7d3df added tokenizer.IgnoreParenthesis() to allow ignoring the parenthesis characters boundary checks 2023-09-17 12:14:57 +03:00
Gani GeorgievandGHOST 71f9be3cb0 [#3323] added Patreon OAuth2 provider
Co-authored-by: GHOST <ghostdevbusiness@gmail.com>
2023-09-16 08:20:49 +03:00
Gani Georgiev f605521208 updated js types docs 2023-09-16 07:07:35 +03:00
Gani Georgiev 4927583790 updated changelog and go deps 2023-09-16 06:54:19 +03:00
Gani Georgiev 6e80cb8136 added more descriptive internal password reset error message 2023-09-15 20:45:28 +03:00
Gani Georgiev bb0a2dd698 [#3310] added headers and cookies fields to the .send result 2023-09-14 14:47:47 +03:00
Gani Georgiev 2608efb56c added array fallback in case of missing joinNonEmpty items 2023-09-12 19:57:42 +03:00
Gani Georgiev eb2aa1cfc6 [#2197] added escape character support for the select field options 2023-09-12 10:29:54 +03:00
Gani Georgiev e1528aedac updated migration comment 2023-09-10 18:33:27 +03:00
Gani Georgiev 22b0a2b586 updated changelog 2023-09-10 10:57:51 +03:00
Gani Georgiev 0ca86a0c87 [#3273] added readerToString() JSVM helper 2023-09-10 10:46:19 +03:00
Gani Georgiev b2c8f394af fixed changelog typo 2023-09-09 12:29:09 +03:00
Gani Georgiev 56b2641469 added hmac jsvm primitives and updated docs 2023-09-09 12:03:34 +03:00
Gani Georgiev f266621a0f updated go deps 2023-09-06 14:13:02 +03:00
Gani Georgiev ca136c5dc1 [#3265] silent the localStorage quota error to prevent breaking the record form panel 2023-09-06 14:11:58 +03:00
Gani Georgiev abfe18bcce [#3261] exclude the local temp dir from the backups 2023-09-06 07:09:28 +03:00
Gani Georgiev f3fc7f78d7 updated changelog 2023-09-05 11:54:09 +03:00
Gani Georgiev 26fd069d11 updated npm deps 2023-09-05 11:49:01 +03:00
Gani Georgiev 62bde9e1f3 updated jsvm types 2023-09-05 11:25:14 +03:00
Gani Georgiev b945cd2fdf updated changelog and ui/dist 2023-09-05 11:23:41 +03:00
Gani Georgiev 89a0520f7d [#3257] normalize pasted text in the editor field 2023-09-05 10:36:42 +03:00
Gani Georgiev a88b3c5db3 updated api docs and enabled paste_as_text editor option 2023-09-05 08:06:21 +03:00
Gani Georgiev a37d9cfb84 updated ui/dist 2023-09-04 11:35:39 +03:00
Gani Georgiev 5b084bbbfd fixed grammar 2023-09-01 14:28:49 +03:00
Gani Georgiev 322508f6d1 registered a custom Deflate compressor to speedup the backups generation 2023-09-01 14:27:23 +03:00
Gani Georgiev 78e70bd52b there is no need to nil the app.settings on ResetBootstrapState 2023-09-01 13:46:33 +03:00
Gani Georgiev 58401459bf updated ui/dist 2023-09-01 12:44:43 +03:00
Gani Georgiev f172754775 removed cgo build archives artifacts 2023-09-01 12:41:49 +03:00
Gani Georgiev 31670ab3e1 log cron job errors 2023-09-01 11:17:09 +03:00
Gani Georgiev baacf4913b updated api preview tabs style 2023-09-01 09:30:49 +03:00
Gani Georgiev 8a94ccea42 updated to Svelte 4 2023-09-01 09:22:49 +03:00
Gani Georgiev b2bab9573a removed forgotten svg icon font declaration 2023-08-30 20:39:39 +03:00
Gani Georgiev e5b5c1f76f added option to auto generate admin and auth record passwords from the Admin UI 2023-08-30 14:59:00 +03:00
Gani Georgiev ccb1c42220 updated jsvm types and removed unnecessary comment 2023-08-29 22:31:33 +03:00
Gani Georgiev a394777264 [#3191] added client-side validation and syntax highlight for the json field 2023-08-29 22:10:57 +03:00
Gani Georgiev 7d10b3c502 updated go deps 2023-08-29 19:23:01 +03:00
Gani Georgiev 64ffb308bb use singular NoDecimal option name 2023-08-29 18:41:20 +03:00
Gani Georgiev 916c74c218 [#3113] added NoDecimal number field option 2023-08-29 18:35:57 +03:00
Gani Georgiev 17974d534e added ellipsis for long backup titles 2023-08-28 21:05:35 +03:00
Gani Georgiev bde7a86b30 [#3066] added the application name as part of the autogenerated backup name for easier identification 2023-08-28 20:36:55 +03:00
Gani Georgiev f7f8f09336 [#2599] added option to upload a backup file from the Admin UI 2023-08-28 20:06:48 +03:00
Gani Georgiev 2a6b891a9b merged with master 2023-08-26 14:46:30 +03:00
Gani Georgiev b4cb35483b updated jsvm types 2023-08-26 14:43:55 +03:00
Gani Georgiev 1606cfd6e2 fixed cronAdd example 2023-08-26 14:34:47 +03:00
Gani Georgiev 08f97ef0bb Merge branch 'master' into develop 2023-08-26 10:38:13 +03:00
Gani Georgiev 0dc263a40c updated go deps and use the new fileblob NoTempDir option 2023-08-26 10:37:12 +03:00
Gani Georgiev 824031e1a4 updated changelog 2023-08-26 10:30:26 +03:00
Gani Georgiev 311bc74b7e [#3025] updated tests.ApiScenario fields 2023-08-25 22:14:04 +03:00
Gani Georgiev 4f3d1682de synced with master 2023-08-25 18:11:09 +03:00
Gani Georgiev 18728732b9 updated ui/dist and go deps 2023-08-25 16:49:59 +03:00
Gani Georgiev bb4f27cfb5 updated automigrate template test 2023-08-25 16:47:41 +03:00
impact-merlinmarekandMerlin Marek d423acad3b [#3192] fixed autogenerated down migration not preserving old rules state
Co-authored-by: Merlin Marek <merlin.marek@posteo.net>
2023-08-25 16:21:04 +03:00
Gani Georgiev ef73052546 added httpAddr default when domain name is missing 2023-08-25 12:06:24 +03:00
Gani Georgiev c89c68a4dc poc of serve domain args 2023-08-25 11:16:31 +03:00
Gani Georgiev 02495554cf [#3175] added jsvm crypto primitives 2023-08-24 11:25:00 +03:00
Gani Georgiev cdbe6d78d3 added basic fields wildcard support 2023-08-23 20:56:38 +03:00
Gani Georgiev ff6904f1f8 removed unnecessary test cases prefix 2023-08-23 16:50:43 +03:00
Gani Georgiev bc0222dcb4 [#3176] skip fields query param transformations for non 20x responses 2023-08-23 16:49:09 +03:00
Gani Georgiev 04826ba588 reduced the default prewarmed goja vms to 25 2023-08-22 22:09:33 +03:00
Gani Georgiev 6ca1f5c431 use crypto if available 2023-08-22 22:01:05 +03:00
Gani Georgiev 2863763a27 added option to control the default TinyMCE urls convert behavior 2023-08-22 14:39:21 +03:00
Gani Georgiev 9c0d952543 fixed isNew checks 2023-08-22 13:01:08 +03:00
Gani Georgiev ed4f7c7358 updated presentable fields sorting 2023-08-22 12:29:54 +03:00
Gani Georgiev 49f1c869c0 synced with master 2023-08-22 10:54:22 +03:00
Gani Georgiev 5e6949062f updated changelog 2023-08-21 18:15:34 +03:00
Gani Georgiev dc063b20fa bumped app version 2023-08-21 18:14:23 +03:00
Gani Georgiev f42bbfd927 updated go deps 2023-08-21 18:10:07 +03:00
Gani Georgiev f0af24d78f use the presentable prop when displaying relations 2023-08-21 18:06:35 +03:00
Gani Georgiev 26fd3d48df added migration to copy existing DisplayFields to the new Presentable field 2023-08-21 12:58:51 +03:00
Gani Georgiev 864bbe7e12 added SchemaField.Presentable field 2023-08-21 12:58:18 +03:00
Gani Georgiev 1e995552c8 updated apis.Serve godoc 2023-08-20 18:31:56 +03:00
Gani Georgiev 6baae97b5d added json marshal fallback for complex structs as placeholder param 2023-08-19 16:33:45 +03:00
Gani Georgiev bcfbbc53f8 [#3147] don't silence connectivity errors 2023-08-18 19:14:13 +03:00
Gani Georgiev 8a916cd636 added datetime macros 2023-08-18 08:48:33 +03:00
Gani Georgiev 75f58a28ac added placeholder params support for Dao.FindRecordsByFilter and Dao.FindFirstRecordByFilter 2023-08-18 06:31:14 +03:00
Gani Georgiev e87ef431c5 added jsvm .* binds 2023-08-17 20:50:00 +03:00
Gani Georgiev b2ac538580 [#3097] added SmtpConfig.LocalName option 2023-08-17 19:07:56 +03:00
Gani Georgiev 53b20ec104 updated LastVerificationSentAt and LastResetSentAt fill sequence 2023-08-17 14:03:11 +03:00
Gani Georgiev c8ef3c4050 updated ui/dist 2023-08-16 22:38:56 +03:00
Gani Georgiev 9113d30103 fixed typo 2023-08-16 17:38:08 +03:00
Gani Georgiev fef6e584b7 updated jsvm types and godoc list formatting 2023-08-15 12:35:52 +03:00
Gani Georgiev 67fa47b1bb [#3132] updated godoc 2023-08-15 12:25:24 +03:00
Gani Georgiev 5f21c4119f [#3132] added common cron expression macros 2023-08-15 12:21:33 +03:00
Gani Georgiev 734f35c504 synced with master 2023-08-15 12:10:21 +03:00
Gani Georgiev 038ae8f803 updated changelog 2023-08-15 01:14:10 +03:00
Gani Georgiev 2236288e57 quoted the wrapped view query columns 2023-08-15 01:09:53 +03:00
Gani Georgiev 8f10c66160 updated ui/dist 2023-08-15 00:58:34 +03:00
Gani Georgiev 5960dc5f2d removed js sdk dto helpers 2023-08-14 21:20:49 +03:00
Gani Georgiev cbf1215bb1 updated jsvm types 2023-08-11 14:40:25 +03:00
Gani Georgiev 1b633720be updated views migrations to use SaveCollection 2023-08-11 14:37:53 +03:00
Gani Georgiev adb5d6e998 [#3110] normalized view queries with numeric or expression ids 2023-08-11 14:29:18 +03:00
Gani Georgiev 3841946b61 downgraded temp gocloud until the os.NoTempDir is released 2023-08-10 21:38:13 +03:00
Gani Georgiev 369e2703c2 updated ui/dist 2023-08-10 08:56:26 +03:00
Gani Georgiev 4a45ad91fa [#3106] always refresh the Admins UI initial admins counter cache when there are none 2023-08-10 08:50:48 +03:00
Nikita Zhenev 265dac45ce [#3103] fixed jsvm registerMigrations error message typo 2023-08-09 18:40:34 +03:00
Gani Georgiev f152787578 updated changelog 2023-08-09 13:17:24 +03:00
Gani Georgiev 640623f4fd [#3098] fixed incorrect cascade delete tooltip message 2023-08-09 12:58:20 +03:00
Gani Georgiev 1aff89f377 use the logs maxDays before firing the goroutine 2023-08-09 12:23:49 +03:00
Gani Georgiev 7d6b12a4ef updated ui/dist 2023-08-08 14:33:08 +03:00
Gani Georgiev 7a3223e415 [#3089] use a temp dir inside pb_data to prevent backups cross-device link error 2023-08-08 14:15:29 +03:00
Gani Georgiev bd18688f35 [#3090] fixed relation to view error message 2023-08-08 12:41:51 +03:00
Gani Georgiev f90da96820 enabled lazy loading for the Admin UI thumb images 2023-08-06 21:51:55 +03:00
Gani Georgiev 6c8f2d2cd6 use scrollbar-gutter to minimize the table records listing layout shifts 2023-08-06 21:44:26 +03:00
Gani Georgiev 5e84305922 fixed changelog grammar 2023-08-05 10:12:22 +03:00
459 changed files with 20790 additions and 45710 deletions

No files matched your search

+2 -2
View File
@@ -16,12 +16,12 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: latest
node-version: 20.8.1
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.20.3'
go-version: '>=1.21.3'
# 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.
+1 -8
View File
@@ -7,6 +7,7 @@ before:
- go mod tidy
builds:
# used only for tests
- id: build_cgo
main: ./examples/base
binary: pocketbase
@@ -46,14 +47,6 @@ release:
draft: true
archives:
- id: archive_cgo
builds: [build_cgo]
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}_cgo'
format: zip
files:
- LICENSE*
- CHANGELOG*
- id: archive_noncgo
builds: [build_noncgo]
format: zip
+461 -6
View File
@@ -1,8 +1,463 @@
## v0.20.0
- Added `expand`, `filter`, `fields`, custom query and headers parameters support for the realtime subscriptions.
_Requires JS SDK v0.20.0+ or Dart SDK v0.17.0+._
```js
// JS SDK v0.20.0
pb.collection("example").subscribe("*", (e) => {
...
}, {
expand: "someRelField",
filter: "status = 'active'",
fields: "id,expand.someRelField.*:excerpt(100)",
})
```
```dart
// Dart SDK v0.17.0
pb.collection("example").subscribe("*", (e) {
...
},
expand: "someRelField",
filter: "status = 'active'",
fields: "id,expand.someRelField.*:excerpt(100)",
)
```
- Generalized the logs to allow any kind of application logs, not just requests.
The new `app.Logger()` implements the standard [`log/slog` interfaces](https://pkg.go.dev/log/slog) available with Go 1.21.
```
// Go: https://pocketbase.io/docs/go-logging/
app.Logger().Info("Example message", "total", 123, "details", "lorem ipsum...")
// JS: https://pocketbase.io/docs/js-logging/
$app.logger().info("Example message", "total", 123, "details", "lorem ipsum...")
```
For better performance and to minimize blocking on hot paths, logs are currently written with
debounce and on batches:
- 3 seconds after the last debounced log write
- when the batch threshold is reached (currently 200)
- right before app termination to attempt saving everything from the existing logs queue
Some notable log related changes:
- ⚠️ Bumped the minimum required Go version to 1.21.
- ⚠️ Removed `_requests` table in favor of the generalized `_logs`.
_Note that existing logs will be deleted!_
- ⚠️ Renamed the following `Dao` log methods:
```go
Dao.RequestQuery(...) -> Dao.LogQuery(...)
Dao.FindRequestById(...) -> Dao.FindLogById(...)
Dao.RequestsStats(...) -> Dao.LogsStats(...)
Dao.DeleteOldRequests(...) -> Dao.DeleteOldLogs(...)
Dao.SaveRequest(...) -> Dao.SaveLog(...)
```
- ⚠️ Removed `app.IsDebug()` and the `--debug` flag.
This was done to avoid the confusion with the new logger and its debug severity level.
If you want to store debug logs you can set `-4` as min log level from the Admin UI.
- Refactored Admin UI Logs:
- Added new logs table listing.
- Added log settings option to toggle the IP logging for the activity logger.
- Added log settings option to specify a minimum log level.
- Added controls to export individual or bulk selected logs as json.
- Other minor improvements and fixes.
- Added new `filesystem/System.Copy(src, dest)` method to copy existing files from one location to another.
_This is usually useful when duplicating records with `file` field(s) programmatically._
- Added `filesystem.NewFileFromUrl(ctx, url)` helper method to construct a `*filesystem.BytesReader` file from the specified url.
- OAuth2 related additions:
- Added new `PKCE()` and `SetPKCE(enable)` OAuth2 methods to indicate whether the PKCE flow is supported or not.
_The PKCE value is currently configurable from the UI only for the OIDC providers._
_This was added to accommodate OIDC providers that may throw an error if unsupported PKCE params are submitted with the auth request (eg. LinkedIn; see [#3799](https://github.com/pocketbase/pocketbase/discussions/3799#discussioncomment-7640312))._
- Added new `displayName` field for each `listAuthMethods()` OAuth2 provider item.
_The value of the `displayName` property is currently configurable from the UI only for the OIDC providers._
- Added `expiry` field to the OAuth2 user response containing the _optional_ expiration time of the OAuth2 access token ([#3617](https://github.com/pocketbase/pocketbase/discussions/3617)).
- Allow a single OAuth2 user to be used for authentication in multiple auth collection.
_⚠️ Because now you can have more than one external provider with `collectionId-provider-providerId` pair, `Dao.FindExternalAuthByProvider(provider, providerId)` method was removed in favour of the more generic `Dao.FindFirstExternalAuthByExpr(expr)`._
- Added `onlyVerified` auth collection option to globally disallow authentication requests for unverified users.
- Added support for single line comments (ex. `// your comment`) in the API rules and filter expressions.
- Added support for specifying a collection alias in `@collection.someCollection:alias.*`.
- Soft-deprecated and renamed `app.Cache()` with `app.Store()`.
- Minor JSVM updates and fixes:
- Updated `$security.parseUnverifiedJWT(token)` and `$security.parseJWT(token, key)` to return the token payload result as plain object.
- Added `$apis.requireGuestOnly()` middleware JSVM binding ([#3896](https://github.com/pocketbase/pocketbase/issues/3896)).
- Use `IS NOT` instead of `!=` as not-equal SQL query operator to handle the cases when comparing with nullable columns or expressions (eg. `json_extract` over `json` field).
_Based on my local dataset I wasn't able to find a significant difference in the performance between the 2 operators, but if you stumble on a query that you think may be affected negatively by this, please report it and I'll test it further._
- Added `MaxSize` `json` field option to prevent storing large json data in the db ([#3790](https://github.com/pocketbase/pocketbase/issues/3790)).
_Existing `json` fields are updated with a system migration to have a ~2MB size limit (it can be adjusted from the Admin UI)._
- Fixed negative string number normalization support for the `json` field type.
- Trigger the `app.OnTerminate()` hook on `app.Restart()` call.
_A new bool `IsRestart` field was also added to the `core.TerminateEvent` event._
- Fixed graceful shutdown handling and speed up a little the app termination time.
- Limit the concurrent thumbs generation to avoid high CPU and memory usage in spiky scenarios ([#3794](https://github.com/pocketbase/pocketbase/pull/3794); thanks @t-muehlberger).
_Currently the max concurrent thumbs generation processes are limited to "total of logical process CPUs + 1"._
_This is arbitrary chosen and may change in the future depending on the users feedback and usage patterns._
_If you are experiencing OOM errors during large image thumb generations, especially in container environment, you can try defining the `GOMEMLIMIT=500MiB` env variable before starting the executable._
- Slightly speed up (~10%) the thumbs generation by changing from cubic (`CatmullRom`) to bilinear (`Linear`) resampling filter (_the quality difference is very little_).
- Added a default red colored Stderr output in case of a console command error.
_You can now also silence individually custom commands errors using the `cobra.Command.SilenceErrors` field._
- Fixed links formatting in the autogenerated html->text mail body.
- Removed incorrectly imported empty `local('')` font-face declarations.
## v0.19.4
- Fixed TinyMCE source code viewer textarea styles ([#3715](https://github.com/pocketbase/pocketbase/issues/3715)).
- Fixed `text` field min/max validators to properly count multi-byte characters ([#3735](https://github.com/pocketbase/pocketbase/issues/3735)).
- Allowed hyphens in `username` ([#3697](https://github.com/pocketbase/pocketbase/issues/3697)).
_More control over the system fields settings will be available in the future._
- Updated the JSVM generated types to use directly the value type instead of `* | undefined` union in functions/methods return declarations.
## v0.19.3
- Added the release notes to the console output of `./pocketbase update` ([#3685](https://github.com/pocketbase/pocketbase/discussions/3685)).
- Added missing documention for the JSVM `$mails.*` bindings.
- Relaxed the OAuth2 redirect url validation to allow any string value ([#3689](https://github.com/pocketbase/pocketbase/pull/3689); thanks @sergeypdev).
_Note that the redirect url format is still bound to the accepted values by the specific OAuth2 provider._
## v0.19.2
- Updated the JSVM generated types ([#3627](https://github.com/pocketbase/pocketbase/issues/3627), [#3662](https://github.com/pocketbase/pocketbase/issues/3662)).
## v0.19.1
- Fixed `tokenizer.Scan()/ScanAll()` to ignore the separators from the default trim cutset.
An option to return also the empty found tokens was also added via `Tokenizer.KeepEmptyTokens(true)`.
_This should fix the parsing of whitespace characters around view query column names when no quotes are used ([#3616](https://github.com/pocketbase/pocketbase/discussions/3616#discussioncomment-7398564))._
- Fixed the `:excerpt(max, withEllipsis?)` `fields` query param modifier to properly add space to the generated text fragment after block tags.
## v0.19.0
- Added Patreon OAuth2 provider ([#3323](https://github.com/pocketbase/pocketbase/pull/3323); thanks @ghostdevv).
- Added mailcow OAuth2 provider ([#3364](https://github.com/pocketbase/pocketbase/pull/3364); thanks @thisni1s).
- Added support for `:excerpt(max, withEllipsis?)` `fields` modifier that will return a short plain text version of any string value (html tags are stripped).
This could be used to minimize the downloaded json data when listing records with large `editor` html values.
```js
await pb.collection("example").getList(1, 20, {
"fields": "*,description:excerpt(100)"
})
```
- Several Admin UI improvements:
- Count the total records separately to speed up the query execution for large datasets ([#3344](https://github.com/pocketbase/pocketbase/issues/3344)).
- Enclosed the listing scrolling area within the table so that the horizontal scrollbar and table header are always reachable ([#2505](https://github.com/pocketbase/pocketbase/issues/2505)).
- Allowed opening the record preview/update form via direct URL ([#2682](https://github.com/pocketbase/pocketbase/discussions/2682)).
- Reintroduced the local `date` field tooltip on hover.
- Speed up the listing loading times for records with large `editor` field values by initially fetching only a partial of the records data (the complete record data is loaded on record preview/update).
- Added "Media library" (collection images picker) support for the TinyMCE `editor` field.
- Added support to "pin" collections in the sidebar.
- Added support to manually resize the collections sidebar.
- More clear "Nonempty" field label style.
- Removed the legacy `.woff` and `.ttf` fonts and keep only `.woff2`.
- Removed the explicit `Content-Type` charset from the realtime response due to compatibility issues with IIS ([#3461](https://github.com/pocketbase/pocketbase/issues/3461)).
_The `Connection:keep-alive` realtime response header was also removed as it is not really used with HTTP2 anyway._
- Added new JSVM bindings:
- `new Cookie({ ... })` constructor for creating `*http.Cookie` equivalent value.
- `new SubscriptionMessage({ ... })` constructor for creating a custom realtime subscription payload.
- Soft-deprecated `$os.exec()` in favour of `$os.cmd()` to make it more clear that the call only prepares the command and doesn't execute it.
- ⚠️ Bumped the min required Go version to 1.19.
## v0.18.10
- Added global `raw` template function to allow outputting raw/verbatim HTML content in the JSVM templates ([#3476](https://github.com/pocketbase/pocketbase/discussions/3476)).
```
{{.description|raw}}
```
- Trimmed view query semicolon and allowed single quotes for column aliases ([#3450](https://github.com/pocketbase/pocketbase/issues/3450#issuecomment-1748044641)).
_Single quotes are usually [not a valid identifier quote characters](https://www.sqlite.org/lang_keywords.html), but for resilience and compatibility reasons SQLite allows them in some contexts where only an identifier is expected._
- Bumped the GitHub action to use [min Go 1.21.2](https://github.com/golang/go/issues?q=milestone%3AGo1.21.2) (_the fixed issues are not critical as they are mostly related to the compiler/build tools_).
## v0.18.9
- Fixed empty thumbs directories not getting deleted on Windows after deleting a record img file ([#3382](https://github.com/pocketbase/pocketbase/issues/3382)).
- Updated the generated JSVM typings to silent the TS warnings when trying to access a field/method in a Go->TS interface.
## v0.18.8
- Minor fix for the View collections API Preview and Admin UI listings incorrectly showing the `created` and `updated` fields as `N/A` when the view query doesn't have them.
## v0.18.7
- Fixed JS error in the Admin UI when listing records with invalid `relation` field value ([#3372](https://github.com/pocketbase/pocketbase/issues/3372)).
_This could happen usually only during custom SQL import scripts or when directly modifying the record field value without data validations._
- Updated Go deps and the generated JSVM types.
## v0.18.6
- Return the response headers and cookies in the `$http.send()` result ([#3310](https://github.com/pocketbase/pocketbase/discussions/3310)).
- Added more descriptive internal error message for missing user/admin email on password reset requests.
- Updated Go deps.
## v0.18.5
- Fixed minor Admin UI JS error in the auth collection options panel introduced with the change from v0.18.4.
## v0.18.4
- Added escape character (`\`) support in the Admin UI to allow using `select` field values with comma ([#2197](https://github.com/pocketbase/pocketbase/discussions/2197)).
## v0.18.3
- Exposed a global JSVM `readerToString(reader)` helper function to allow reading Go `io.Reader` values ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)).
- Bumped the GitHub action to use [min Go 1.21.1](https://github.com/golang/go/issues?q=milestone%3AGo1.21.1+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `html/template` and `net/http` security fixes.
## v0.18.2
- Prevent breaking the record form in the Admin UI in case the browser's localStorage quota has been exceeded when uploading or storing large `editor` values ([#3265](https://github.com/pocketbase/pocketbase/issues/3265)).
- Updated docs and missing JSVM typings.
- Exposed additional crypto primitives under the `$security.*` JSVM namespace ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)):
```js
// HMAC with SHA256
$security.hs256("hello", "secret")
// HMAC with SHA512
$security.hs512("hello", "secret")
// compare 2 strings with a constant time
$security.equal(hash1, hash2)
```
## v0.18.1
- Excluded the local temp dir from the backups ([#3261](https://github.com/pocketbase/pocketbase/issues/3261)).
## v0.18.0
- Simplified the `serve` command to accept domain name(s) as argument to reduce any additional manual hosts setup that sometimes previously was needed when deploying on production ([#3190](https://github.com/pocketbase/pocketbase/discussions/3190)).
```sh
./pocketbase serve yourdomain.com
```
- Added `fields` wildcard (`*`) support.
- Added option to upload a backup file from the Admin UI ([#2599](https://github.com/pocketbase/pocketbase/issues/2599)).
- Registered a custom Deflate compressor to speedup (_nearly 2-3x_) the backups generation for the sake of a small zip size increase.
_Based on several local tests, `pb_data` of ~500MB (from which ~350MB+ are several hundred small files) results in a ~280MB zip generated for ~11s (previously it resulted in ~250MB zip but for ~35s)._
- Added the application name as part of the autogenerated backup name for easier identification ([#3066](https://github.com/pocketbase/pocketbase/issues/3066)).
- Added new `SmtpConfig.LocalName` option to specify a custom domain name (or IP address) for the initial EHLO/HELO exchange ([#3097](https://github.com/pocketbase/pocketbase/discussions/3097)).
_This is usually required for verification purposes only by some SMTP providers, such as on-premise [Gmail SMTP-relay](https://support.google.com/a/answer/2956491)._
- Added `NoDecimal` `number` field option.
- `editor` field improvements:
- Added new "Strip urls domain" option to allow controlling the default TinyMCE urls behavior (_default to `false` for new content_).
- Normalized pasted text while still preserving links, lists, tables, etc. formatting ([#3257](https://github.com/pocketbase/pocketbase/issues/3257)).
- Added option to auto generate admin and auth record passwords from the Admin UI.
- Added JSON validation and syntax highlight for the `json` field in the Admin UI ([#3191](https://github.com/pocketbase/pocketbase/issues/3191)).
- Added datetime filter macros:
```
// all macros are UTC based
@second - @now second number (0-59)
@minute - @now minute number (0-59)
@hour - @now hour number (0-23)
@weekday - @now weekday number (0-6)
@day - @now day number
@month - @now month number
@year - @now year number
@todayStart - beginning of the current day as datetime string
@todayEnd - end of the current day as datetime string
@monthStart - beginning of the current month as datetime string
@monthEnd - end of the current month as datetime string
@yearStart - beginning of the current year as datetime string
@yearEnd - end of the current year as datetime string
```
- Added cron expression macros ([#3132](https://github.com/pocketbase/pocketbase/issues/3132)):
```
@yearly - "0 0 1 1 *"
@annually - "0 0 1 1 *"
@monthly - "0 0 1 * *"
@weekly - "0 0 * * 0"
@daily - "0 0 * * *"
@midnight - "0 0 * * *"
@hourly - "0 * * * *"
```
- ⚠️ Added offset argument `Dao.FindRecordsByFilter(collection, filter, sort, limit, offset, [params...])`.
_If you don't need an offset, you can set it to `0`._
- To minimize the footguns with `Dao.FindFirstRecordByFilter()` and `Dao.FindRecordsByFilter()`, the functions now supports an optional placeholder params argument that is safe to be populated with untrusted user input.
The placeholders are in the same format as when binding regular SQL parameters.
```go
// unsanitized and untrusted filter variables
status := "..."
author := "..."
app.Dao().FindFirstRecordByFilter("articles", "status={:status} && author={:author}", dbx.Params{
"status": status,
"author": author,
})
app.Dao().FindRecordsByFilter("articles", "status={:status} && author={:author}", "-created", 10, 0, dbx.Params{
"status": status,
"author": author,
})
```
- Added JSVM `$mails.*` binds for the corresponding Go [mails package](https://pkg.go.dev/github.com/pocketbase/pocketbase/mails) functions.
- Added JSVM helper crypto primitives under the `$security.*` namespace:
```js
$security.md5(text)
$security.sha256(text)
$security.sha512(text)
```
- ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections.
- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
- ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)).
- ⚠️ Changes to `tests.ApiScenario` struct:
- The `ApiScenario.AfterTestFunc` now receive as 3rd argument `*http.Response` pointer instead of `*echo.Echo` as the latter is not really useful in this context.
```go
// old
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo)
// new
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response)
```
- The `ApiScenario.TestAppFactory` now accept the test instance as argument and no longer expect an error as return result ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025#discussioncomment-6592272)).
```go
// old
TestAppFactory: func() (*tests.TestApp, error)
// new
TestAppFactory: func(t *testing.T) *tests.TestApp
```
_Returning a `nil` app instance from the factory results in test failure. You can enforce a custom test failure by calling `t.Fatal(err)` inside the factory._
- Bumped the min required TLS version to 1.2 in order to improve the cert reputation score.
- Reduced the default JSVM prewarmed pool size to 25 to reduce the initial memory consumptions (_you can manually adjust the pool size with `--hooksPool=50` if you need to, but the default should suffice for most cases_).
- Update `gocloud.dev` dependency to v0.34 and explicitly set the new `NoTempDir` fileblob option to prevent the cross-device link error introduced with v0.33.
- Other minor Admin UI and docs improvements.
## v0.17.7
- Fixed the autogenerated `down` migrations to properly revert the old collection rules in case a change was made in `up` ([#3192](https://github.com/pocketbase/pocketbase/pull/3192); thanks @impact-merlinmarek).
_Existing `down` migrations can't be fixed but that should be ok as usually the `down` migrations are rarely used against prod environments since they can cause data loss and, while not ideal, the previous old behavior of always setting the rules to `null/nil` is safer than not updating the rules at all._
- Updated some Go deps.
## v0.17.6
- Fixed JSVM `require()` file path error when using Windows-style path delimiters ([#3163](https://github.com/pocketbase/pocketbase/issues/3163#issuecomment-1685034438)).
## v0.17.5
- Added quotes around the wrapped view query columns introduced with v0.17.4.
## v0.17.4
- Fixed Views record retrieval when numeric id is used ([#3110](https://github.com/pocketbase/pocketbase/issues/3110)).
_With this fix we also now properly recognize `CAST(... as TEXT)` and `CAST(... as BOOLEAN)` as `text` and `bool` fields._
- Fixed `relation` "Cascade delete" tooltip message ([#3098](https://github.com/pocketbase/pocketbase/issues/3098)).
- Fixed jsvm error message prefix on failed migrations ([#3103](https://github.com/pocketbase/pocketbase/pull/3103); thanks @nzhenev).
- Disabled the initial Admin UI admins counter cache when there are no initial admins to allow detecting externally created accounts (eg. with the `admin` command) ([#3106](https://github.com/pocketbase/pocketbase/issues/3106)).
- Downgraded `google/go-cloud` dependency to v0.32.0 until v0.34.0 is released to prevent the `os.TempDir` `cross-device link` errors as too many users complained about it.
## v0.17.3
- Fixed Docker `cross-device link` error when creating `pb_data` backups on a local mounted volume ([#3089](https://github.com/pocketbase/pocketbase/issues/3089)).
- Fixed the error messages for relation to views ([#3090](https://github.com/pocketbase/pocketbase/issues/3090)).
- Always reserve space for the scrollbar to reduce the layout shifts in the Admin UI records listing due to the deprecated `overflow: overlay`.
- Enabled lazy loading for the Admin UI thumb images.
## v0.17.2
- Soft-deprecated `$http.send({ data: object, ... })` in favour of `$http.send({ body: rawString, ... })`
to allow sending non-JSON body with the request ([#3058](https://github.com/pocketbase/pocketbase/discussions/3058)).
The existing `data` prop will still work, but it will be recommended to use `body` instead (_to send JSON you can use `JSON.stringify(...)` as body value_).
The existing `data` prop will still work, but it is recommended to use `body` instead (_to send JSON you can use `JSON.stringify(...)` as body value_).
- Added `core.RealtimeConnectEvent.IdleTimeout` field to allow specifying a different realtime idle timeout duration per client basis ([#3054](https://github.com/pocketbase/pocketbase/discussions/3054)).
@@ -696,7 +1151,7 @@
_Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
- ! Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
- ⚠️ Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
If you want a "at least one of" type of condition, you can prefix the operator with `?`.
```js
// for each someRelA.someRelB record require the "status" field to be "active"
@@ -776,7 +1231,7 @@
## v0.10.3
- ! Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
- ⚠️ Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
@@ -812,7 +1267,7 @@
- Refactored the `core.app.Bootstrap()` to be called before starting the cobra commands ([#1267](https://github.com/pocketbase/pocketbase/discussions/1267)).
- ! Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
- ⚠️ Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
```go
DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns
DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns
@@ -822,9 +1277,9 @@
- Added new helper method `core.App.IsBootstrapped()` to check the current app bootstrap state.
- ! Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
- ⚠️ Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
- ! Removed `rest.UploadedFile` struct (see below `filesystem.File`).
- ⚠️ Removed `rest.UploadedFile` struct (see below `filesystem.File`).
- Added generic file resource struct that allows loading and uploading file content from
different sources (at the moment multipart/form-data requests and from the local filesystem).
+21 -19
View File
@@ -1,5 +1,4 @@
Contributing to PocketBase
======================================================================
# Contributing to PocketBase
Thanks for taking the time to improve PocketBase!
@@ -9,26 +8,26 @@ This document describes how to prepare a PR for a change in the main repository.
- [Making changes in the Go code](#making-changes-in-the-go-code)
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
## Prerequisites
- Go 1.18+ (for making changes in the Go code)
- Go 1.19+ (for making changes in the Go code)
- Node 16+ (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:
```
git clone https://github.com/your_username/pocketbase.git
```
> [!IMPORTANT]
> It is recommended to create a new branch from master for each of your bugfixes and features.
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
## Making changes in the Go code
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
The repository already includes such program, located in `examples/base`, that is also used for the prebuilt executables.
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
@@ -41,20 +40,22 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
To run the tests, you could execute (while in the root project directory):
```sh
go test ./...
# or using the Makefile
make test
```
```sh
go test ./...
# or using the Makefile
make test
```
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
```sh
golangci-lint run -c ./golangci.yml ./...
# or using the Makefile
make lint
```
```sh
golangci-lint run -c ./golangci.yml ./...
# or using the Makefile
make lint
```
## Making changes in the Admin UI
@@ -65,14 +66,15 @@ To start the Admin UI:
1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies
3. Start vite's dev server
```sh
npm run dev
```
```sh
npm run dev
```
You could open the browser and access the running Admin UI at `http://localhost:3000`.
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
> [!NOTE]
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
+11 -10
View File
@@ -19,10 +19,10 @@
**For documentation and examples, please visit https://pocketbase.io/docs.**
> ⚠️ Please keep in mind that PocketBase is still under active development
> [!WARNING]
> Please keep in mind that PocketBase is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## API SDK clients
The easiest way to interact with the API is to use one of the official SDK clients:
@@ -30,7 +30,6 @@ The easiest way to interact with the API is to use one of the official SDK clien
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview
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
@@ -39,10 +38,9 @@ your own custom app specific business logic and still have a single portable exe
### Installation
```sh
# go 1.18+
# go 1.21+
go get github.com/pocketbase/pocketbase
```
> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
### Example
@@ -95,14 +93,15 @@ Enable CGO only if you really need to squeeze the read/write query performance a
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
0. [Install Go 1.21+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo
2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
(_https://go.dev/doc/install/source#environment_)
(_https://go.dev/doc/install/source#environment_)
4. Start the created executable by running `./base serve`.
The supported build targets by the non-cgo driver at the moment are:
```
darwin amd64
darwin arm64
@@ -114,6 +113,7 @@ linux arm
linux arm64
linux ppc64le
linux riscv64
linux s390x
windows amd64
windows arm64
```
@@ -122,6 +122,7 @@ windows arm64
PocketBase comes with mixed bag of unit and integration tests.
To run them, use the default `go test` command:
```sh
go test ./...
```
@@ -134,7 +135,6 @@ If you discover a security vulnerability within PocketBase, please send an e-mai
All reports will be promptly addressed, and you'll be credited accordingly.
## Contributing
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
@@ -154,5 +154,6 @@ PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
_Please also note that 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._
> [!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.
+2 -4
View File
@@ -1,7 +1,6 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
@@ -129,9 +128,8 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Admin); err != nil && api.app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
if err := next(e.Admin); err != nil {
api.app.Logger().Error("Failed to send admin password reset request.", "error", err)
}
})
+30 -6
View File
@@ -2,7 +2,6 @@ package apis
import (
"context"
"log"
"net/http"
"path/filepath"
"time"
@@ -11,6 +10,8 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -24,6 +25,7 @@ func bindBackupApi(app core.App, rg *echo.Group) {
subGroup := rg.Group("/backups", ActivityLogger(app))
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuth())
subGroup.POST("/upload", api.upload, RequireAdminAuth())
subGroup.GET("/:key", api.download)
subGroup.DELETE("/:key", api.delete, RequireAdminAuth())
subGroup.POST("/:key/restore", api.restore, RequireAdminAuth())
@@ -66,7 +68,7 @@ func (api *backupApi) list(c echo.Context) error {
}
func (api *backupApi) create(c echo.Context) error {
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
if api.app.Store().Has(core.StoreKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
}
@@ -88,6 +90,28 @@ func (api *backupApi) create(c echo.Context) error {
})
}
func (api *backupApi) upload(c echo.Context) error {
files, err := rest.FindUploadedFiles(c.Request(), "file")
if err != nil {
return NewBadRequestError("Missing or invalid uploaded file.", err)
}
form := forms.NewBackupUpload(api.app)
form.File = files[0]
return form.Submit(func(next forms.InterceptorNextFunc[*filesystem.File]) forms.InterceptorNextFunc[*filesystem.File] {
return func(file *filesystem.File) error {
if err := next(file); err != nil {
return NewBadRequestError("Failed to upload backup.", err)
}
// we don't retrieve the generated backup file because it may not be
// available yet due to the eventually consistent nature of some S3 providers
return c.NoContent(http.StatusNoContent)
}
})
}
func (api *backupApi) download(c echo.Context) error {
fileToken := c.QueryParam("token")
@@ -127,7 +151,7 @@ func (api *backupApi) download(c echo.Context) error {
}
func (api *backupApi) restore(c echo.Context) error {
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
if api.app.Store().Has(core.StoreKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
}
@@ -156,8 +180,8 @@ func (api *backupApi) restore(c echo.Context) error {
// give some optimistic time to write the response
time.Sleep(1 * time.Second)
if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() {
log.Println(err)
if err := api.app.RestoreBackup(ctx, key); err != nil {
api.app.Logger().Error("Failed to restore backup", "key", key, "error", err.Error())
}
}()
@@ -178,7 +202,7 @@ func (api *backupApi) delete(c echo.Context) error {
key := c.PathParam("key")
if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key {
if key != "" && cast.ToString(api.app.Store().Get(core.StoreKeyActiveBackup)) == key {
return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
}
+152 -16
View File
@@ -1,7 +1,11 @@
package apis_test
import (
"archive/zip"
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
@@ -85,7 +89,7 @@ func TestBackupsCreate(t *testing.T) {
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/backups",
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
@@ -98,7 +102,7 @@ func TestBackupsCreate(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
@@ -112,9 +116,9 @@ func TestBackupsCreate(t *testing.T) {
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.Cache().Set(core.CacheKeyActiveBackup, "")
app.Store().Set(core.StoreKeyActiveBackup, "")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
@@ -127,7 +131,7 @@ func TestBackupsCreate(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -152,7 +156,7 @@ func TestBackupsCreate(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
@@ -169,7 +173,7 @@ func TestBackupsCreate(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -193,6 +197,138 @@ func TestBackupsCreate(t *testing.T) {
}
}
func TestBackupsUpload(t *testing.T) {
// create dummy form data bodies
type body struct {
buffer io.Reader
contentType string
}
bodies := make([]body, 10)
for i := 0; i < 10; i++ {
func() {
zb := new(bytes.Buffer)
zw := zip.NewWriter(zb)
if err := zw.Close(); err != nil {
t.Fatal(err)
}
b := new(bytes.Buffer)
mw := multipart.NewWriter(b)
mfw, err := mw.CreateFormFile("file", "test")
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(mfw, zb); err != nil {
t.Fatal(err)
}
mw.Close()
bodies[i] = body{
buffer: b,
contentType: mw.FormDataContentType(),
}
}()
}
// ---
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[0].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[0].contentType,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[1].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[1].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (missing file)",
Method: http.MethodPost,
Url: "/api/backups/upload",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{`},
},
{
Name: "authorized as admin (existing backup name)",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[3].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[3].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
fsys, err := app.NewBackupsFilesystem()
if err != nil {
t.Fatal(err)
}
defer fsys.Close()
// create a dummy backup file to simulate existing backups
if err := fsys.Upload([]byte("123"), "test"); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"file":{`},
},
{
Name: "authorized as admin (valid file)",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[4].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[4].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
}
},
ExpectedStatus: 204,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsDownload(t *testing.T) {
scenarios := []tests.ApiScenario{
{
@@ -371,7 +507,7 @@ func TestBackupsDelete(t *testing.T) {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 401,
@@ -389,7 +525,7 @@ func TestBackupsDelete(t *testing.T) {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 401,
@@ -407,7 +543,7 @@ func TestBackupsDelete(t *testing.T) {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
@@ -426,9 +562,9 @@ func TestBackupsDelete(t *testing.T) {
}
// mock active backup with the same name to delete
app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip")
app.Store().Set(core.StoreKeyActiveBackup, "test1.zip")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
@@ -447,9 +583,9 @@ func TestBackupsDelete(t *testing.T) {
}
// mock active backup with different name
app.Cache().Set(core.CacheKeyActiveBackup, "new.zip")
app.Store().Set(core.StoreKeyActiveBackup, "new.zip")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -481,7 +617,7 @@ func TestBackupsDelete(t *testing.T) {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -564,7 +700,7 @@ func TestBackupsRestore(t *testing.T) {
t.Fatal(err)
}
app.Cache().Set(core.CacheKeyActiveBackup, "")
app.Store().Set(core.StoreKeyActiveBackup, "")
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+38 -39
View File
@@ -6,11 +6,12 @@ import (
"errors"
"fmt"
"io/fs"
"log"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
@@ -26,9 +27,9 @@ const trailedAdminPath = "/_/"
// system and app specific routes and middlewares.
func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New()
e.Debug = app.IsDebug()
e.Debug = false
e.JSONSerializer = &rest.Serializer{
FieldsParam: "fields",
FieldsParam: fieldsQueryParam,
}
// configure a custom router
@@ -49,6 +50,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
e.Pre(LoadAuthContext(app))
e.Use(middleware.Recover())
e.Use(middleware.Secure())
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set(ContextExecStartKey, time.Now())
return next(c)
}
})
// custom error handler
e.HTTPErrorHandler = func(c echo.Context, err error) {
@@ -56,30 +64,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
return // no error
}
if c.Response().Committed {
if app.IsDebug() {
log.Println("HTTPErrorHandler response was already committed:", err)
}
return
}
var apiErr *ApiError
if errors.As(err, &apiErr) {
if app.IsDebug() && apiErr.RawData() != nil {
log.Println(apiErr.RawData())
}
// already an api error...
} else if v := new(echo.HTTPError); errors.As(err, &v) {
if v.Internal != nil && app.IsDebug() {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message)
apiErr = NewApiError(v.Code, msg, v)
} else {
if app.IsDebug() {
log.Println(err)
}
if errors.Is(err, sql.ErrNoRows) {
apiErr = NewNotFoundError("", err)
} else {
@@ -87,13 +79,19 @@ func InitApi(app core.App) (*echo.Echo, error) {
}
}
logRequest(app, c, apiErr)
if c.Response().Committed {
return // already commited
}
event := new(core.ApiErrorEvent)
event.HttpContext = c
event.Error = apiErr
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
if c.Response().Committed {
if e.HttpContext.Response().Committed {
return nil
}
@@ -106,12 +104,11 @@ func InitApi(app core.App) (*echo.Echo, error) {
})
if hookErr == nil {
if err := app.OnAfterApiError().Trigger(event); err != nil && app.IsDebug() {
log.Println(hookErr)
if err := app.OnAfterApiError().Trigger(event); err != nil {
app.Logger().Debug("OnAfterApiError failure", slog.String("error", hookErr.Error()))
}
} else if app.IsDebug() {
// truly rare case; eg. client already disconnected
log.Println(hookErr)
} else {
app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error()))
}
}
@@ -207,15 +204,15 @@ func uiCacheControl() echo.MiddlewareFunc {
}
}
const totalAdminsCacheKey = "@totalAdmins"
const hasAdminsCacheKey = "@hasAdmins"
func updateTotalAdminsCache(app core.App) error {
func updateHasAdminsCache(app core.App) error {
total, err := app.Dao().TotalAdmins()
if err != nil {
return err
}
app.Cache().Set(totalAdminsCacheKey, total)
app.Store().Set(hasAdminsCacheKey, total > 0)
return nil
}
@@ -223,12 +220,13 @@ func updateTotalAdminsCache(app core.App) error {
// installerRedirect redirects the user to the installer admin UI page
// when the application needs some preliminary configurations to be done.
func installerRedirect(app core.App) echo.MiddlewareFunc {
// keep totalAdminsCacheKey value up-to-date
// keep hasAdminsCacheKey value up-to-date
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
return updateTotalAdminsCache(app)
return updateHasAdminsCache(app)
})
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
return updateTotalAdminsCache(app)
return updateHasAdminsCache(app)
})
return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -239,23 +237,24 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
return next(c)
}
// load into cache (if not already)
if !app.Cache().Has(totalAdminsCacheKey) {
if err := updateTotalAdminsCache(app); err != nil {
hasAdmins := cast.ToBool(app.Store().Get(hasAdminsCacheKey))
if !hasAdmins {
// update the cache to make sure that the admin wasn't created by another process
if err := updateHasAdminsCache(app); err != nil {
return err
}
hasAdmins = cast.ToBool(app.Store().Get(hasAdminsCacheKey))
}
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
_, hasInstallerParam := c.Request().URL.Query()["installer"]
if totalAdmins == 0 && !hasInstallerParam {
if !hasAdmins && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
if hasAdmins && hasInstallerParam {
// clear the installer param
return c.Redirect(http.StatusTemporaryRedirect, "?")
}
+21 -103
View File
@@ -12,7 +12,6 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -47,7 +46,7 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":10`,
`"totalItems":11`,
`"items":[{`,
`"id":"_pb_users_auth_"`,
`"id":"v851q4r790rhknl"`,
@@ -57,6 +56,7 @@ func TestCollectionsList(t *testing.T) {
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"9n89pl5vkct6330"`,
`"id":"ib3m2700k5hlsjz"`,
`"type":"auth"`,
`"type":"base"`,
},
@@ -75,9 +75,9 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
`"totalItems":10`,
`"totalItems":11`,
`"items":[{`,
`"id":"kpv709sk2lqbqk8"`,
`"id":"v9gwnfh02gjq1q0"`,
`"id":"9n89pl5vkct6330"`,
},
ExpectedEvents: map[string]int{
@@ -245,7 +245,7 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
@@ -264,7 +264,7 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
@@ -400,7 +400,7 @@ func TestCollectionCreate(t *testing.T) {
`"name":"new"`,
`"type":"base"`,
`"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"options":{}`,
},
ExpectedEvents: map[string]int{
@@ -424,8 +424,8 @@ func TestCollectionCreate(t *testing.T) {
`"name":"new"`,
`"type":"auth"`,
`"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"onlyVerified":false,"requireEmail":false}`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
@@ -693,7 +693,7 @@ func TestCollectionCreate(t *testing.T) {
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
@@ -823,7 +823,7 @@ func TestCollectionUpdate(t *testing.T) {
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
// check if the record table was renamed
if !app.Dao().HasTable("new") {
t.Fatal("Couldn't find record table 'new'.")
@@ -959,88 +959,6 @@ func TestCollectionUpdate(t *testing.T) {
},
},
// rel field change displayFields propagation
// -----------------------------------------------------------
{
Name: "renaming a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"id": "w5z2x0nq",
"type": "text",
"name": "title_change"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"title_change"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"title_change", "id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
{
Name: "deleting a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"type": "text",
"name": "new_field"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new_field"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
// view
// -----------------------------------------------------------
{
@@ -1142,7 +1060,7 @@ func TestCollectionUpdate(t *testing.T) {
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
@@ -1164,7 +1082,7 @@ func TestCollectionUpdate(t *testing.T) {
}
func TestCollectionsImport(t *testing.T) {
totalCollections := 10
totalCollections := 11
scenarios := []tests.ApiScenario{
{
@@ -1197,7 +1115,7 @@ func TestCollectionsImport(t *testing.T) {
`"data":{`,
`"collections":{"code":"validation_required"`,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1223,9 +1141,9 @@ func TestCollectionsImport(t *testing.T) {
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 4,
"OnModelBeforeDelete": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1267,7 +1185,7 @@ func TestCollectionsImport(t *testing.T) {
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeCreate": 2,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1323,7 +1241,7 @@ func TestCollectionsImport(t *testing.T) {
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1421,14 +1339,14 @@ func TestCollectionsImport(t *testing.T) {
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 8,
"OnModelAfterDelete": 8,
"OnModelBeforeDelete": 9,
"OnModelAfterDelete": 9,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
+68 -9
View File
@@ -1,19 +1,26 @@
package apis
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"runtime"
"strings"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
"golang.org/x/sync/semaphore"
"golang.org/x/sync/singleflight"
)
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
@@ -21,7 +28,12 @@ var defaultThumbSizes = []string{"100x100"}
// bindFileApi registers the file api endpoints and the corresponding handlers.
func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app}
api := fileApi{
app: app,
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 1)), // the value is arbitrary chosen and may change in the future
thumbGenPending: new(singleflight.Group),
thumbGenMaxWait: 60 * time.Second,
}
subGroup := rg.Group("/files", ActivityLogger(app))
subGroup.POST("/token", api.fileToken)
@@ -31,6 +43,18 @@ func bindFileApi(app core.App, rg *echo.Group) {
type fileApi struct {
app core.App
// thumbGenSem is a semaphore to prevent too much concurrent
// requests generating new thumbs at the same time.
thumbGenSem *semaphore.Weighted
// thumbGenPending represents a group of currently pending
// thumb generation processes.
thumbGenPending *singleflight.Group
// thumbGenMaxWait is the maximum waiting time for starting a new
// thumb generation process.
thumbGenMaxWait time.Duration
}
func (api *fileApi) fileToken(c echo.Context) error {
@@ -124,11 +148,11 @@ func (api *fileApi) download(c echo.Context) error {
baseFilesPath = fileRecord.BaseFilesPath()
}
fs, err := api.app.NewFilesystem()
fsys, err := api.app.NewFilesystem()
if err != nil {
return NewBadRequestError("Filesystem initialization failure.", err)
}
defer fs.Close()
defer fsys.Close()
originalPath := baseFilesPath + "/" + filename
servedPath := originalPath
@@ -138,7 +162,7 @@ func (api *fileApi) download(c echo.Context) error {
thumbSize := c.QueryParam("thumb")
if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) {
// extract the original file meta attributes and check it existence
oAttrs, oAttrsErr := fs.Attributes(originalPath)
oAttrs, oAttrsErr := fsys.Attributes(originalPath)
if oAttrsErr != nil {
return NewNotFoundError("", err)
}
@@ -149,10 +173,19 @@ func (api *fileApi) download(c echo.Context) error {
servedName = thumbSize + "_" + filename
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
// create a new thumb if it doesn exists
if exists, _ := fs.Exists(servedPath); !exists {
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
servedPath = originalPath // fallback to the original
// create a new thumb if it doesn't exist
if exists, _ := fsys.Exists(servedPath); !exists {
if err := api.createThumb(c, fsys, originalPath, servedPath, thumbSize); err != nil {
api.app.Logger().Warn(
"Fallback to original - failed to create thumb "+servedName,
slog.Any("error", err),
slog.String("original", originalPath),
slog.String("thumb", servedPath),
)
// fallback to the original
servedName = filename
servedPath = originalPath
}
}
}
@@ -176,7 +209,7 @@ func (api *fileApi) download(c echo.Context) error {
return nil
}
if err := fs.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
if err := fsys.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
return NewNotFoundError("", err)
}
@@ -214,3 +247,29 @@ func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.M
return nil, errors.New("missing or invalid file token")
}
func (api *fileApi) createThumb(
c echo.Context,
fsys *filesystem.System,
originalPath string,
thumbPath string,
thumbSize string,
) error {
ch := api.thumbGenPending.DoChan(thumbPath, func() (any, error) {
ctx, cancel := context.WithTimeout(c.Request().Context(), api.thumbGenMaxWait)
defer cancel()
if err := api.thumbGenSem.Acquire(ctx, 1); err != nil {
return nil, err
}
defer api.thumbGenSem.Release(1)
return nil, fsys.CreateThumb(originalPath, thumbPath, thumbSize)
})
res := <-ch
api.thumbGenPending.Forget(thumbPath)
return res.Err
}
+81
View File
@@ -2,15 +2,19 @@ package apis_test
import (
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"runtime"
"sync"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -385,3 +389,80 @@ func TestFileDownload(t *testing.T) {
scenario.Test(t)
}
}
func TestConcurrentThumbsGeneration(t *testing.T) {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
defer app.Cleanup()
fsys, err := app.NewFilesystem()
if err != nil {
t.Fatal(err)
}
defer fsys.Close()
// create a dummy file field collection
demo1, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
fileField := demo1.Schema.GetFieldByName("file_one")
fileField.Options = &schema.FileOptions{
Protected: false,
MaxSelect: 1,
MaxSize: 999999,
// new thumbs
Thumbs: []string{"111x111", "111x222", "111x333"},
}
demo1.Schema.AddField(fileField)
if err := app.Dao().SaveCollection(demo1); err != nil {
t.Fatal(err)
}
fileKey := "wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png"
e, err := apis.InitApi(app)
if err != nil {
t.Fatal(err)
}
urls := []string{
"/api/files/" + fileKey + "?thumb=111x111",
"/api/files/" + fileKey + "?thumb=111x111", // should still result in single thumb
"/api/files/" + fileKey + "?thumb=111x222",
"/api/files/" + fileKey + "?thumb=111x333",
}
var wg sync.WaitGroup
wg.Add(len(urls))
for _, url := range urls {
url := url
go func() {
defer wg.Done()
recorder := httptest.NewRecorder()
req := httptest.NewRequest("GET", url, nil)
e.ServeHTTP(recorder, req)
}()
}
wg.Wait()
// ensure that all new requested thumbs were created
thumbKeys := []string{
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x111_" + filepath.Base(fileKey),
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x222_" + filepath.Base(fileKey),
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x333_" + filepath.Base(fileKey),
}
for _, k := range thumbKeys {
if exists, _ := fsys.Exists(k); !exists {
t.Fatalf("Missing thumb %q: %v", k, err)
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (api *healthApi) healthCheck(c echo.Context) error {
resp := new(healthCheckResponse)
resp.Code = http.StatusOK
resp.Message = "API is healthy."
resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup)
resp.Data.CanBackup = !api.app.Store().Has(core.StoreKeyActiveBackup)
return c.JSON(http.StatusOK, resp)
}
+18 -18
View File
@@ -15,27 +15,27 @@ func bindLogsApi(app core.App, rg *echo.Group) {
api := logsApi{app: app}
subGroup := rg.Group("/logs", RequireAdminAuth())
subGroup.GET("/requests", api.requestsList)
subGroup.GET("/requests/stats", api.requestsStats)
subGroup.GET("/requests/:id", api.requestView)
subGroup.GET("", api.list)
subGroup.GET("/stats", api.stats)
subGroup.GET("/:id", api.view)
}
type logsApi struct {
app core.App
}
var requestFilterFields = []string{
var logFilterFields = []string{
"rowid", "id", "created", "updated",
"url", "method", "status", "auth",
"remoteIp", "userIp", "referer", "userAgent",
"level", "message", "data",
`^data\.[\w\.\:]*\w+$`,
}
func (api *logsApi) requestsList(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
func (api *logsApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
result, err := search.NewProvider(fieldResolver).
Query(api.app.LogsDao().RequestQuery()).
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
Query(api.app.LogsDao().LogQuery()).
ParseAndExec(c.QueryParams().Encode(), &[]*models.Log{})
if err != nil {
return NewBadRequestError("", err)
@@ -44,8 +44,8 @@ func (api *logsApi) requestsList(c echo.Context) error {
return c.JSON(http.StatusOK, result)
}
func (api *logsApi) requestsStats(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
func (api *logsApi) stats(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
filter := c.QueryParam(search.FilterQueryParam)
@@ -58,24 +58,24 @@ func (api *logsApi) requestsStats(c echo.Context) error {
}
}
stats, err := api.app.LogsDao().RequestsStats(expr)
stats, err := api.app.LogsDao().LogsStats(expr)
if err != nil {
return NewBadRequestError("Failed to generate requests stats.", err)
return NewBadRequestError("Failed to generate logs stats.", err)
}
return c.JSON(http.StatusOK, stats)
}
func (api *logsApi) requestView(c echo.Context) error {
func (api *logsApi) view(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return NewNotFoundError("", nil)
}
request, err := api.app.LogsDao().FindRequestById(id)
if err != nil || request == nil {
log, err := api.app.LogsDao().FindLogById(id)
if err != nil || log == nil {
return NewNotFoundError("", err)
}
return c.JSON(http.StatusOK, request)
return c.JSON(http.StatusOK, log)
}
+21 -21
View File
@@ -8,19 +8,19 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestRequestsList(t *testing.T) {
func TestLogsList(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/logs/requests",
Url: "/api/logs",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests",
Url: "/api/logs",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
@@ -30,12 +30,12 @@ func TestRequestsList(t *testing.T) {
{
Name: "authorized as admin",
Method: http.MethodGet,
Url: "/api/logs/requests",
Url: "/api/logs",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
if err := tests.MockLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -52,12 +52,12 @@ func TestRequestsList(t *testing.T) {
{
Name: "authorized as admin + filter",
Method: http.MethodGet,
Url: "/api/logs/requests?filter=status>200",
Url: "/api/logs?filter=data.status>200",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
if err := tests.MockLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -77,19 +77,19 @@ func TestRequestsList(t *testing.T) {
}
}
func TestRequestView(t *testing.T) {
func TestLogView(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
@@ -99,12 +99,12 @@ func TestRequestView(t *testing.T) {
{
Name: "authorized as admin (nonexisting request log)",
Method: http.MethodGet,
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
Url: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
if err := tests.MockLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -114,12 +114,12 @@ func TestRequestView(t *testing.T) {
{
Name: "authorized as admin (existing request log)",
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
if err := tests.MockLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -135,19 +135,19 @@ func TestRequestView(t *testing.T) {
}
}
func TestRequestsStats(t *testing.T) {
func TestLogsStats(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
Url: "/api/logs/stats",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
Url: "/api/logs/stats",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
@@ -157,12 +157,12 @@ func TestRequestsStats(t *testing.T) {
{
Name: "authorized as admin",
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
Url: "/api/logs/stats",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
if err := tests.MockLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -174,12 +174,12 @@ func TestRequestsStats(t *testing.T) {
{
Name: "authorized as admin + filter",
Method: http.MethodGet,
Url: "/api/logs/requests/stats?filter=status>200",
Url: "/api/logs/stats?filter=data.status>200",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
if err := tests.MockLogsData(app); err != nil {
t.Fatal(err)
}
},
+73 -72
View File
@@ -2,7 +2,7 @@ package apis
import (
"fmt"
"log"
"log/slog"
"net"
"net/http"
"strings"
@@ -15,7 +15,6 @@ import (
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -24,6 +23,7 @@ const (
ContextAdminKey string = "admin"
ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection"
ContextExecStartKey string = "execStart"
)
// RequireGuestOnly middleware requires a request to NOT have a valid
@@ -285,84 +285,85 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
func ActivityLogger(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
// no logs retention
if app.Settings().Logs.MaxDays == 0 {
if err := next(c); err != nil {
return err
}
httpRequest := c.Request()
httpResponse := c.Response()
status := httpResponse.Status
meta := types.JsonMap{}
logRequest(app, c, nil)
if err != nil {
switch v := err.(type) {
case *echo.HTTPError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal)
case *ApiError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData())
default:
status = http.StatusBadRequest
meta["errorMessage"] = v.Error()
}
}
requestAuth := models.RequestAuthGuest
if c.Get(ContextAuthRecordKey) != nil {
requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin
}
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
model := &models.Request{
Url: httpRequest.URL.RequestURI(),
Method: strings.ToUpper(httpRequest.Method),
Status: status,
Auth: requestAuth,
UserIp: realUserIp(httpRequest, ip),
RemoteIp: ip,
Referer: httpRequest.Referer(),
UserAgent: httpRequest.UserAgent(),
Meta: meta,
}
// set timestamp fields before firing a new go routine
model.RefreshCreated()
model.RefreshUpdated()
routine.FireAndForget(func() {
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
log.Println("Log save failed:", err)
}
// Delete old request logs
// ---
now := time.Now()
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
if daysDiff > float64(app.Settings().Logs.MaxDays) {
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
if deleteErr == nil {
app.Cache().Set("lastLogsDeletedAt", now)
} else if app.IsDebug() {
log.Println("Logs delete failed:", deleteErr)
}
}
})
return err
return nil
}
}
}
func logRequest(app core.App, c echo.Context, err *ApiError) {
// no logs retention
if app.Settings().Logs.MaxDays == 0 {
return
}
attrs := make([]any, 0, 15)
attrs = append(attrs, slog.String("type", "request"))
started := cast.ToTime(c.Get(ContextExecStartKey))
if !started.IsZero() {
attrs = append(attrs, slog.Float64("execTime", float64(time.Since(started))/float64(time.Millisecond)))
}
httpRequest := c.Request()
httpResponse := c.Response()
method := strings.ToUpper(httpRequest.Method)
status := httpResponse.Status
url := httpRequest.URL.RequestURI()
// parse the request error
if err != nil {
status = err.Code
attrs = append(
attrs,
slog.String("error", err.Message),
slog.Any("details", err.RawData()),
)
}
requestAuth := models.RequestAuthGuest
if c.Get(ContextAuthRecordKey) != nil {
requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin
}
attrs = append(
attrs,
slog.String("url", url),
slog.String("method", method),
slog.Int("status", status),
slog.String("auth", requestAuth),
slog.String("referer", httpRequest.Referer()),
slog.String("userAgent", httpRequest.UserAgent()),
)
if app.Settings().Logs.LogIp {
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
attrs = append(
attrs,
slog.String("userIp", realUserIp(httpRequest, ip)),
slog.String("remoteIp", ip),
)
}
// don't block on logs write
routine.FireAndForget(func() {
message := method + " " + url
if err != nil {
app.Logger().Error(message, attrs...)
} else {
app.Logger().Info(message, attrs...)
}
})
}
// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
//
// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
+221 -129
View File
@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"log"
"log/slog"
"net/http"
"strings"
"time"
@@ -15,18 +15,20 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/spf13/cast"
)
// bindRealtimeApi registers the realtime api endpoints.
func bindRealtimeApi(app core.App, rg *echo.Group) {
api := realtimeApi{app: app}
subGroup := rg.Group("/realtime", ActivityLogger(app))
subGroup := rg.Group("/realtime")
subGroup.GET("", api.connect)
subGroup.POST("", api.setSubscriptions)
subGroup.POST("", api.setSubscriptions, ActivityLogger(app))
api.bindEvents()
}
@@ -49,16 +51,19 @@ func (api *realtimeApi) connect(c echo.Context) error {
Client: client,
}
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
log.Println(err)
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil {
api.app.Logger().Debug(
"OnRealtimeDisconnectRequest error",
slog.String("clientId", client.Id()),
slog.String("error", err.Error()),
)
}
api.app.SubscriptionsBroker().Unregister(client.Id())
}()
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-store")
c.Response().Header().Set("Connection", "keep-alive")
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
c.Response().Header().Set("X-Accel-Buffering", "no")
@@ -73,9 +78,7 @@ func (api *realtimeApi) connect(c echo.Context) error {
return err
}
if api.app.IsDebug() {
log.Printf("Realtime connection established: %s\n", client.Id())
}
api.app.Logger().Debug("Realtime connection established.", slog.String("clientId", client.Id()))
// signalize established connection (aka. fire "connect" message)
connectMsgEvent := &core.RealtimeMessageEvent{
@@ -97,9 +100,11 @@ func (api *realtimeApi) connect(c echo.Context) error {
return api.app.OnRealtimeAfterMessageSend().Trigger(e)
})
if connectMsgErr != nil {
if api.app.IsDebug() {
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
}
api.app.Logger().Debug(
"Realtime connection closed (failed to deliver PB_CONNECT)",
slog.String("clientId", client.Id()),
slog.String("error", connectMsgErr.Error()),
)
return nil
}
@@ -115,9 +120,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
case msg, ok := <-client.Channel():
if !ok {
// channel is closed
if api.app.IsDebug() {
log.Println("Realtime connection closed (closed channel):", client.Id())
}
api.app.Logger().Debug(
"Realtime connection closed (closed channel)",
slog.String("clientId", client.Id()),
)
return nil
}
@@ -137,9 +143,11 @@ func (api *realtimeApi) connect(c echo.Context) error {
return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
})
if msgErr != nil {
if api.app.IsDebug() {
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
}
api.app.Logger().Debug(
"Realtime connection closed (failed to deliver message)",
slog.String("clientId", client.Id()),
slog.String("error", msgErr.Error()),
)
return nil
}
@@ -147,9 +155,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
idleTimer.Reset(idleTimeout)
case <-c.Request().Context().Done():
// connection is closed
if api.app.IsDebug() {
log.Println("Realtime connection closed (cancelled request):", client.Id())
}
api.app.Logger().Debug(
"Realtime connection closed (cancelled request)",
slog.String("clientId", client.Id()),
)
return nil
}
}
@@ -199,6 +208,12 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// subscribe to the new subscriptions
e.Client.Subscribe(e.Subscriptions...)
api.app.Logger().Debug(
"Realtime subscriptions updated.",
slog.String("clientId", e.Client.Id()),
slog.Any("subscriptions", e.Subscriptions),
)
return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
if e.HttpContext.Response().Committed {
return nil
@@ -266,8 +281,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("create", record, false); err != nil && api.app.IsDebug() {
log.Println(err)
if err := api.broadcastRecord("create", record, false); err != nil {
api.app.Logger().Debug(
"Failed to broadcast record create",
slog.String("id", record.Id),
slog.String("collectionName", record.Collection().Name),
slog.String("error", err.Error()),
)
}
}
return nil
@@ -275,8 +295,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("update", record, false); err != nil && api.app.IsDebug() {
log.Println(err)
if err := api.broadcastRecord("update", record, false); err != nil {
api.app.Logger().Debug(
"Failed to broadcast record update",
slog.String("id", record.Id),
slog.String("collectionName", record.Collection().Name),
slog.String("error", err.Error()),
)
}
}
return nil
@@ -284,8 +309,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("delete", record, true); err != nil && api.app.IsDebug() {
log.Println(err)
if err := api.broadcastRecord("delete", record, true); err != nil {
api.app.Logger().Debug(
"Failed to dry cache record delete",
slog.String("id", record.Id),
slog.String("collectionName", record.Collection().Name),
slog.String("error", err.Error()),
)
}
}
return nil
@@ -293,8 +323,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastDryCachedRecord("delete", record); err != nil && api.app.IsDebug() {
log.Println(err)
if err := api.broadcastDryCachedRecord("delete", record); err != nil {
api.app.Logger().Debug(
"Failed to broadcast record delete",
slog.String("id", record.Id),
slog.String("collectionName", record.Collection().Name),
slog.String("error", err.Error()),
)
}
}
return nil
@@ -327,58 +362,16 @@ func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection
return collection
}
// canAccessRecord checks if the subscription client has access to the specified record model.
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
// admins can access everything
return true
}
if accessRule == nil {
// only admins can access this record
return false
}
ruleFunc := func(q *dbx.SelectQuery) error {
if *accessRule == "" {
return nil // empty public rule
}
// mock request data
requestInfo := &models.RequestInfo{
Method: "GET",
}
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
if err == nil && foundRecord != nil {
return true
}
return false
}
// recordData represents the broadcasted record subscrition message data.
type recordData struct {
Action string `json:"action"`
Record *models.Record `json:"record"`
Record any `json:"record"` /* map or models.Record */
Action string `json:"action"`
}
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
collection := record.Collection()
if collection == nil {
return errors.New("Record collection not set.")
return errors.New("[broadcastRecord] Record collection not set.")
}
clients := api.app.SubscriptionsBroker().Clients()
@@ -386,67 +379,125 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
return nil // no subscribers
}
// create a clean record copy without expand and unknown fields
// because we don't know if the clients have permissions to view them
cleanRecord := record.CleanCopy()
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
(collection.Name + "/*"): collection.ListRule,
(collection.Id + "/*"): collection.ListRule,
// @deprecated: the same as the wildcard topic but kept for backward compatibility
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
(collection.Name + "/" + record.Id + "?"): collection.ViewRule,
(collection.Id + "/" + record.Id + "?"): collection.ViewRule,
(collection.Name + "/*?"): collection.ListRule,
(collection.Id + "/*?"): collection.ListRule,
// @deprecated: the same as the wildcard topic but kept for backward compatibility
(collection.Name + "?"): collection.ListRule,
(collection.Id + "?"): collection.ListRule,
}
data := &recordData{
Action: action,
Record: cleanRecord,
}
dataBytes, err := json.Marshal(data)
if err != nil {
return err
}
dryCacheKey := action + "/" + record.Id
for _, client := range clients {
client := client
for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) {
// note: not executed concurrently to avoid races and to ensure
// that the access checks are applied for the current record db state
for prefix, rule := range subscriptionRuleMap {
subs := client.Subscriptions(prefix)
if len(subs) == 0 {
continue
}
if !api.canAccessRecord(client, data.Record, rule) {
continue
}
for sub, options := range subs {
// create a clean record copy without expand and unknown fields
// because we don't know yet which exact fields the client subscription has permissions to access
cleanRecord := record.CleanCopy()
msg := subscriptions.Message{
Name: subscription,
Data: dataBytes,
}
// ignore the auth record email visibility checks for
// auth owner, admin or manager
if collection.IsAuth() {
authId := extractAuthIdFromGetter(client)
if authId == data.Record.Id ||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
data.Record.IgnoreEmailVisibility(true) // ignore
if newData, err := json.Marshal(data); err == nil {
msg.Data = newData
}
data.Record.IgnoreEmailVisibility(false) // restore
// mock request data
requestInfo := &models.RequestInfo{
Method: "GET",
Query: options.Query,
Headers: options.Headers,
}
}
requestInfo.Admin, _ = client.Get(ContextAdminKey).(*models.Admin)
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
if dryCache {
client.Set(action+"/"+data.Record.Id, msg)
} else {
routine.FireAndForget(func() {
client.Send(msg)
})
if !api.canAccessRecord(cleanRecord, requestInfo, rule) {
continue
}
rawExpand := cast.ToString(options.Query[expandQueryParam])
if rawExpand != "" {
expandErrs := api.app.Dao().ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(api.app.Dao(), requestInfo))
if len(expandErrs) > 0 {
api.app.Logger().Debug(
"[broadcastRecord] expand errors",
slog.String("id", cleanRecord.Id),
slog.String("collectionName", cleanRecord.Collection().Name),
slog.String("sub", sub),
slog.String("expand", rawExpand),
slog.Any("errors", expandErrs),
)
}
}
// ignore the auth record email visibility checks
// for auth owner, admin or manager
if collection.IsAuth() {
authId := extractAuthIdFromGetter(client)
if authId == cleanRecord.Id {
if api.canAccessRecord(cleanRecord, requestInfo, collection.AuthOptions().ManageRule) {
cleanRecord.IgnoreEmailVisibility(true)
}
}
}
data := &recordData{
Action: action,
Record: cleanRecord,
}
// check fields
rawFields := cast.ToString(options.Query[fieldsQueryParam])
if rawFields != "" {
decoded, err := rest.PickFields(cleanRecord, rawFields)
if err == nil {
data.Record = decoded
} else {
api.app.Logger().Debug(
"[broadcastRecord] pick fields error",
slog.String("id", cleanRecord.Id),
slog.String("collectionName", cleanRecord.Collection().Name),
slog.String("sub", sub),
slog.String("fields", rawFields),
slog.String("error", err.Error()),
)
}
}
dataBytes, err := json.Marshal(data)
if err != nil {
api.app.Logger().Debug(
"[broadcastRecord] data marshal error",
slog.String("id", cleanRecord.Id),
slog.String("collectionName", cleanRecord.Collection().Name),
slog.String("error", err.Error()),
)
continue
}
msg := subscriptions.Message{
Name: sub,
Data: dataBytes,
}
if dryCache {
messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
if !ok {
messages = []subscriptions.Message{msg}
} else {
messages = append(messages, msg)
}
client.Set(dryCacheKey, messages)
} else {
routine.FireAndForget(func() {
client.Send(msg)
})
}
}
}
}
@@ -454,14 +505,14 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
return nil
}
// broadcastDryCachedRecord broadcasts record if it is cached in the client context.
// broadcastDryCachedRecord broadcasts all cached record related messages.
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
key := action + "/" + record.Id
clients := api.app.SubscriptionsBroker().Clients()
for _, client := range clients {
key := action + "/" + record.Id
msg, ok := client.Get(key).(subscriptions.Message)
messages, ok := client.Get(key).([]subscriptions.Message)
if !ok {
continue
}
@@ -471,9 +522,12 @@ func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.R
client := client
routine.FireAndForget(func() {
client.Send(msg)
for _, msg := range messages {
client.Send(msg)
}
})
}
return nil
}
@@ -494,3 +548,41 @@ func extractAuthIdFromGetter(val getter) string {
return ""
}
// canAccessRecord checks if the subscription client has access to the specified record model.
func (api *realtimeApi) canAccessRecord(
record *models.Record,
requestInfo *models.RequestInfo,
accessRule *string,
) bool {
// check the access rule
// ---
if ok, _ := api.app.Dao().CanAccessRecord(record, requestInfo, accessRule); !ok {
return false
}
// check the subscription client-side filter (if any)
// ---
filter := cast.ToString(requestInfo.Query[search.FilterQueryParam])
if filter == "" {
return true // no further checks needed
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false)
expr, err := search.FilterData(filter).BuildExpr(resolver)
if err != nil {
return err
}
q.AndWhere(expr)
resolver.UpdateQuery(q)
return nil
}
_, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
return err == nil
}
+8 -8
View File
@@ -34,7 +34,7 @@ func TestRealtimeConnect(t *testing.T) {
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -58,7 +58,7 @@ func TestRealtimeConnect(t *testing.T) {
return nil
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -79,7 +79,7 @@ func TestRealtimeConnect(t *testing.T) {
return hook.StopPropagation
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -124,7 +124,7 @@ func TestRealtimeSubscribe(t *testing.T) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(client.Subscriptions()) != 0 {
t.Errorf("Expected no subscriptions, got %v", client.Subscriptions())
}
@@ -145,7 +145,7 @@ func TestRealtimeSubscribe(t *testing.T) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
expectedSubs := []string{"test1", "test2"}
if len(expectedSubs) != len(client.Subscriptions()) {
t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions())
@@ -175,7 +175,7 @@ func TestRealtimeSubscribe(t *testing.T) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if admin == nil {
t.Errorf("Expected admin auth model, got nil")
@@ -199,7 +199,7 @@ func TestRealtimeSubscribe(t *testing.T) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
@@ -224,7 +224,7 @@ func TestRealtimeSubscribe(t *testing.T) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
+58 -36
View File
@@ -4,8 +4,9 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"sort"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -73,12 +74,15 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
}
type providerInfo struct {
Name string `json:"name"`
State string `json:"state"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
State string `json:"state"`
AuthUrl string `json:"authUrl"`
// technically could be omitted if the provider doesn't support PKCE,
// but to avoid breaking existing typed clients we'll return them as empty string
CodeVerifier string `json:"codeVerifier"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
AuthUrl string `json:"authUrl"`
}
func (api *recordAuthApi) authMethods(c echo.Context) error {
@@ -90,12 +94,14 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
authOptions := collection.AuthOptions()
result := struct {
AuthProviders []providerInfo `json:"authProviders"`
UsernamePassword bool `json:"usernamePassword"`
EmailPassword bool `json:"emailPassword"`
AuthProviders []providerInfo `json:"authProviders"`
OnlyVerified bool `json:"onlyVerified"`
}{
UsernamePassword: authOptions.AllowUsernameAuth,
EmailPassword: authOptions.AllowEmailAuth,
OnlyVerified: authOptions.OnlyVerified,
AuthProviders: []providerInfo{},
}
@@ -111,51 +117,61 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
provider, err := auth.NewProviderByName(name)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
}
api.app.Logger().Debug("Missing or invalid provier name", slog.String("name", name))
continue // skip provider
}
if err := config.SetupProvider(provider); err != nil {
if api.app.IsDebug() {
log.Println(err)
}
api.app.Logger().Debug(
"Failed to setup provider",
slog.String("name", name),
slog.String("error", err.Error()),
)
continue // skip provider
}
state := security.RandomString(30)
codeVerifier := security.RandomString(43)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
urlOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
info := providerInfo{
Name: name,
DisplayName: provider.DisplayName(),
State: security.RandomString(30),
}
if info.DisplayName == "" {
info.DisplayName = name
}
urlOpts := []oauth2.AuthCodeOption{}
// custom providers url options
switch name {
case auth.NameApple:
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
case auth.NameVK:
// vk currently doesn't support PKCE for server-side authorization
urlOpts = []oauth2.AuthCodeOption{}
}
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
urlOpts...,
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
})
if provider.PKCE() {
info.CodeVerifier = security.RandomString(43)
info.CodeChallenge = security.S256Challenge(info.CodeVerifier)
info.CodeChallengeMethod = "S256"
urlOpts = append(urlOpts,
oauth2.SetAuthURLParam("code_challenge", info.CodeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", info.CodeChallengeMethod),
)
}
info.AuthUrl = provider.BuildAuthUrl(
info.State,
urlOpts...,
) + "&redirect_uri=" // empty redirect_uri so that users can append their redirect url
result.AuthProviders = append(result.AuthProviders, info)
}
// sort providers
sort.SliceStable(result.AuthProviders, func(i, j int) bool {
return result.AuthProviders[i].Name < result.AuthProviders[j].Name
})
return c.JSON(http.StatusOK, result)
}
@@ -327,8 +343,11 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
if err := next(e.Record); err != nil {
api.app.Logger().Debug(
"Failed to send password reset email",
slog.String("error", err.Error()),
)
}
})
@@ -416,8 +435,11 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
if err := next(e.Record); err != nil {
api.app.Logger().Debug(
"Failed to send verification email",
slog.String("error", err.Error()),
)
}
})
+83 -4
View File
@@ -40,6 +40,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
ExpectedContent: []string{
`"usernamePassword":true`,
`"emailPassword":true`,
`"onlyVerified":false`,
`"authProviders":[{`,
`"name":"gitlab"`,
`"state":`,
@@ -58,6 +59,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
ExpectedContent: []string{
`"usernamePassword":false`,
`"emailPassword":true`,
`"onlyVerified":true`,
`"authProviders":[]`,
},
},
@@ -212,7 +214,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
},
},
{
Name: "valid email and valid password in allowed collection",
Name: "valid email (unverified) and valid password in allowed collection",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
Body: strings.NewReader(`{
@@ -225,6 +227,48 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"token":"`,
`"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`,
`"verified":false`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
},
},
// onlyVerified collection check
{
Name: "unverified user in onlyVerified collection",
Method: http.MethodPost,
Url: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"test2@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 403,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
},
},
{
Name: "verified user in onlyVerified collection",
Method: http.MethodPost,
Url: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"test@example.com",
"password":"1234567890"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"id":"gk390qegs4y47wn"`,
`"email":"test@example.com"`,
`"verified":true`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
@@ -377,6 +421,41 @@ func TestRecordAuthRefresh(t *testing.T) {
"OnRecordAfterAuthRefreshRequest": 1,
},
},
{
Name: "unverified auth record in onlyVerified collection",
Method: http.MethodPost,
Url: "/api/collections/clients/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im8xeTBkZDBzcGQ3ODZtZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.-JYlrz5DcGzvb0nYx-xqnSFMu9dupyKY7Vg_FUm0OaM",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthRefreshRequest": 1,
"OnRecordAfterAuthRefreshRequest": 1,
},
},
{
Name: "verified auth record in onlyVerified collection",
Method: http.MethodPost,
Url: "/api/collections/clients/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"record":`,
`"id":"gk390qegs4y47wn"`,
`"verified":true`,
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthRefreshRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordAfterAuthRefreshRequest": 1,
},
},
{
Name: "OnRecordAfterAuthRefreshRequest error response",
Method: http.MethodPost,
@@ -898,7 +977,7 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":`,
`"newEmail":{"code":"validation_record_email_exists"`,
`"newEmail":{"code":"validation_record_email_invalid"`,
},
},
{
@@ -1188,7 +1267,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
"OnRecordAfterUnlinkExternalAuthRequest": 1,
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
@@ -1234,7 +1313,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
"OnRecordAfterUnlinkExternalAuthRequest": 1,
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
+25 -12
View File
@@ -2,7 +2,7 @@ package apis
import (
"fmt"
"log"
"log/slog"
"net/http"
"strings"
@@ -16,8 +16,6 @@ import (
"github.com/pocketbase/pocketbase/tools/search"
)
const expandQueryParam = "expand"
// bindRecordCrudApi registers the record crud api endpoints and
// the corresponding handlers.
func bindRecordCrudApi(app core.App, rg *echo.Group) {
@@ -76,7 +74,7 @@ func (api *recordApi) list(c echo.Context) error {
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
if err != nil {
return NewBadRequestError("Invalid filter parameters.", err)
return NewBadRequestError("", err)
}
event := new(core.RecordsListEvent)
@@ -90,8 +88,8 @@ func (api *recordApi) list(c echo.Context) error {
return nil
}
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
log.Println(err)
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil {
api.app.Logger().Debug("Failed to enrich list records", slog.String("error", err.Error()))
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -144,8 +142,13 @@ func (api *recordApi) view(c echo.Context) error {
return nil
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
api.app.Logger().Debug(
"Failed to enrich view record",
slog.String("id", e.Record.Id),
slog.String("collectionName", e.Record.Collection().Name),
slog.String("error", err.Error()),
)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
@@ -237,8 +240,13 @@ func (api *recordApi) create(c echo.Context) error {
return NewBadRequestError("Failed to create record.", err)
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
api.app.Logger().Debug(
"Failed to enrich create record",
slog.String("id", e.Record.Id),
slog.String("collectionName", e.Record.Collection().Name),
slog.String("error", err.Error()),
)
}
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
@@ -324,8 +332,13 @@ func (api *recordApi) update(c echo.Context) error {
return NewBadRequestError("Failed to update record.", err)
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
api.app.Logger().Debug(
"Failed to enrich update record",
slog.String("id", e.Record.Id),
slog.String("collectionName", e.Record.Collection().Name),
slog.String("error", err.Error()),
)
}
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
+31 -5
View File
@@ -462,6 +462,22 @@ func TestRecordCrudList(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "view collection with numeric ids",
Method: http.MethodGet,
Url: "/api/collections/numeric_id_view/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":2`,
`"items":[{`,
`"id":"1"`,
`"id":"2"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -731,6 +747,16 @@ func TestRecordCrudView(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "view record with numeric id",
Method: http.MethodGet,
Url: "/api/collections/numeric_id_view/records/1",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"1"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -877,7 +903,7 @@ func TestRecordCrudDelete(t *testing.T) {
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "_pb_users_auth_", "4q1xlclmfloku33")
// check if all the external auths records were deleted
@@ -964,7 +990,7 @@ func TestRecordCrudDelete(t *testing.T) {
"OnRecordBeforeDeleteRequest": 1,
"OnRecordAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
recId := "84nmscqy84lsi1t"
rec, _ := app.Dao().FindRecordById("demo1", recId, nil)
if rec != nil {
@@ -2006,7 +2032,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, _ := app.Dao().FindRecordById("nologin", "phhq3wr65cap535")
if !record.ValidatePassword("12345678") {
t.Fatal("Password update failed.")
@@ -2049,7 +2075,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
if !record.ValidatePassword("12345678") {
t.Fatal("Password update failed.")
@@ -2111,7 +2137,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, _ := app.Dao().FindRecordById("nologin", "dc49k6jgejn40h3")
if !record.ValidatePassword("123456789") {
t.Fatal("Password update failed.")
+12 -3
View File
@@ -3,6 +3,7 @@ package apis
import (
"fmt"
"log"
"log/slog"
"net/http"
"strings"
@@ -13,12 +14,16 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
const ContextRequestInfoKey = "requestInfo"
const expandQueryParam = "expand"
const fieldsQueryParam = "fields"
// Deprecated: Use RequestInfo instead.
func RequestData(c echo.Context) *models.RequestInfo {
log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
@@ -49,7 +54,7 @@ func RequestInfo(c echo.Context) *models.RequestInfo {
// ("X-Token" is converted to "x_token")
for k, v := range c.Request().Header {
if len(v) > 0 {
result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
result.Headers[inflector.Snakecase(k)] = v[0]
}
}
@@ -72,6 +77,10 @@ func RecordAuthResponse(
meta any,
finalizers ...func(token string) error,
) error {
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified {
return NewForbiddenError("Please verify your email first.", nil)
}
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
@@ -104,8 +113,8 @@ func RecordAuthResponse(
expands,
expandFetch(app.Dao(), &requestInfo),
)
if len(failed) > 0 && app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
if len(failed) > 0 {
app.Logger().Debug("[RecordAuthResponse] Failed to expand relations", slog.Any("errors", failed))
}
}
+10
View File
@@ -83,6 +83,11 @@ func TestRecordAuthResponse(t *testing.T) {
t.Fatal(err)
}
unverfiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
record *models.Record
@@ -97,6 +102,11 @@ func TestRecordAuthResponse(t *testing.T) {
record: nonAuthRecord,
expectError: true,
},
{
name: "valid auth record but with unverified email in onlyVerified collection",
record: unverfiedAuthRecord,
expectError: true,
},
{
name: "valid auth record - without meta",
record: authRecord,
+104 -7
View File
@@ -8,14 +8,17 @@ import (
"net/http"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fatih/color"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/migrate"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
@@ -26,17 +29,35 @@ type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
ShowStartBanner bool
// HttpAddr is the HTTP server address to bind (eg. `127.0.0.1:80`).
// HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`).
HttpAddr string
// HttpsAddr is the HTTPS server address to bind (eg. `127.0.0.1:443`).
// HttpsAddr is the TCP address to listen for the HTTPS server (eg. `127.0.0.1:443`).
HttpsAddr string
// Optional domains list to use when issuing the TLS certificate.
//
// If not set, the host from the bound server address will be used.
//
// For convenience, for each "non-www" domain a "www" entry and
// redirect will be automatically added.
CertificateDomains []string
// AllowedOrigins is an optional list of CORS origins (default to "*").
AllowedOrigins []string
}
// Serve starts a new app web server.
//
// NB! The app should be bootstrapped before starting the web server.
//
// Example:
//
// app.Bootstrap()
// apis.Serve(app, apis.ServeConfig{
// HttpAddr: "127.0.0.1:8080",
// ShowStartBanner: false,
// })
func Serve(app core.App, config ServeConfig) (*http.Server, error) {
if len(config.AllowedOrigins) == 0 {
config.AllowedOrigins = []string{"*"}
@@ -75,16 +96,58 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
mainAddr = config.HttpsAddr
}
mainHost, _, _ := net.SplitHostPort(mainAddr)
var wwwRedirects []string
// extract the host names for the certificate host policy
hostNames := config.CertificateDomains
if len(hostNames) == 0 {
host, _, _ := net.SplitHostPort(mainAddr)
hostNames = append(hostNames, host)
}
for _, host := range hostNames {
if strings.HasPrefix(host, "www.") {
continue // explicitly set www host
}
wwwHost := "www." + host
if !list.ExistInSlice(wwwHost, hostNames) {
hostNames = append(hostNames, wwwHost)
wwwRedirects = append(wwwRedirects, wwwHost)
}
}
// implicit www->non-www redirect(s)
if len(wwwRedirects) > 0 {
router.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
host := c.Request().Host
if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, wwwRedirects) {
return c.Redirect(
http.StatusTemporaryRedirect,
(c.Scheme() + "://" + host[4:] + c.Request().RequestURI),
)
}
return next(c)
}
})
}
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
HostPolicy: autocert.HostWhitelist(hostNames...),
}
// base request context used for cancelling long running requests
// like the SSE connections
baseCtx, cancelBaseCtx := context.WithCancel(context.Background())
defer cancelBaseCtx()
server := &http.Server{
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
@@ -93,6 +156,9 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Addr: mainAddr,
BaseContext: func(l net.Listener) context.Context {
return baseCtx
},
}
serveEvent := &core.ServeEvent{
@@ -107,8 +173,14 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
if config.ShowStartBanner {
schema := "http"
addr := server.Addr
if config.HttpsAddr != "" {
schema = "https"
if len(config.CertificateDomains) > 0 {
addr = config.CertificateDomains[0]
}
}
date := new(strings.Builder)
@@ -118,22 +190,47 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, server.Addr),
color.CyanString("%s://%s", schema, addr),
)
regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, server.Addr))
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, server.Addr))
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
}
// WaitGroup to block until server.ShutDown() returns because Serve and similar methods exit immediately.
// Note that the WaitGroup would not do anything if the app.OnTerminate() hook isn't triggered.
var wg sync.WaitGroup
// try to gracefully shutdown the server on app termination
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
cancelBaseCtx()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
wg.Add(1)
server.Shutdown(ctx)
if e.IsRestart {
// wait for execve and other handlers up to 5 seconds before exit
time.AfterFunc(5*time.Second, func() {
wg.Done()
})
} else {
wg.Done()
}
return nil
})
// wait for the graceful shutdown to complete before exit
defer wg.Wait()
// ---
// @todo consider removing the server return value because it is
// not really useful when combined with the blocking serve calls
// ---
// start HTTPS server
if config.HttpsAddr != "" {
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
+9 -3
View File
@@ -80,6 +80,8 @@ func TestSettingsList(t *testing.T) {
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -161,6 +163,8 @@ func TestSettingsSet(t *testing.T) {
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -231,6 +235,8 @@ func TestSettingsSet(t *testing.T) {
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -400,7 +406,7 @@ func TestSettingsTestEmail(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
@@ -435,7 +441,7 @@ func TestSettingsTestEmail(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
@@ -470,7 +476,7 @@ func TestSettingsTestEmail(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
+25 -9
View File
@@ -17,14 +17,30 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
var httpsAddr string
command := &cobra.Command{
Use: "serve",
Short: "Starts the web server (default to 127.0.0.1:8090)",
Use: "serve [domain(s)]",
Args: cobra.ArbitraryArgs,
Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
Run: func(command *cobra.Command, args []string) {
// set default listener addresses if at least one domain is specified
if len(args) > 0 {
if httpAddr == "" {
httpAddr = "0.0.0.0:80"
}
if httpsAddr == "" {
httpsAddr = "0.0.0.0:443"
}
} else {
if httpAddr == "" {
httpAddr = "127.0.0.1:8090"
}
}
_, err := apis.Serve(app, apis.ServeConfig{
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
ShowStartBanner: showStartBanner,
AllowedOrigins: allowedOrigins,
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
ShowStartBanner: showStartBanner,
AllowedOrigins: allowedOrigins,
CertificateDomains: args,
})
if err != http.ErrServerClosed {
@@ -43,15 +59,15 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command.PersistentFlags().StringVar(
&httpAddr,
"http",
"127.0.0.1:8090",
"api HTTP server address",
"",
"TCP address to listen for the HTTP server\n(if domain args are specified - default to 0.0.0.0:80, otherwise - default to 127.0.0.1:8090)",
)
command.PersistentFlags().StringVar(
&httpsAddr,
"https",
"",
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incoming --http address traffic also will be redirected to this address",
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
)
return command
+8 -5
View File
@@ -5,6 +5,7 @@ package core
import (
"context"
"log/slog"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
@@ -48,6 +49,9 @@ type App interface {
// the users table from LogsDao will result in error.
LogsDao() *daos.Dao
// Logger returns the active app logger.
Logger() *slog.Logger
// DataDir returns the app data directory path.
DataDir() string
@@ -55,16 +59,15 @@ type App interface {
// (used for settings encryption).
EncryptionEnv() string
// IsDebug returns whether the app is in debug mode
// (showing more detailed error logs, executed sql statements, etc.).
IsDebug() bool
// Settings returns the loaded app settings.
Settings() *settings.Settings
// Cache returns the app internal cache store.
// Deprecated: Use app.Store() instead.
Cache() *store.Store[any]
// Store returns the app runtime store.
Store() *store.Store[any]
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
SubscriptionsBroker() *subscriptions.Broker
+156 -51
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"log"
"log/slog"
"os"
"path/filepath"
"runtime"
@@ -18,10 +19,14 @@ import (
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
const (
@@ -39,8 +44,9 @@ var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct {
// @todo consider introducing a mutex to allow safe concurrent config changes during runtime
// configurable parameters
isDebug bool
dataDir string
encryptionEnv string
dataMaxOpenConns int
@@ -49,11 +55,12 @@ type BaseApp struct {
logsMaxIdleConns int
// internals
cache *store.Store[any]
store *store.Store[any]
settings *settings.Settings
dao *daos.Dao
logsDao *daos.Dao
subscriptionsBroker *subscriptions.Broker
logger *slog.Logger
// app event hooks
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
@@ -169,7 +176,6 @@ type BaseApp struct {
type BaseAppConfig struct {
DataDir string
EncryptionEnv string
IsDebug bool
DataMaxOpenConns int // default to 500
DataMaxIdleConns int // default 20
LogsMaxOpenConns int // default to 100
@@ -183,13 +189,12 @@ type BaseAppConfig struct {
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
dataDir: config.DataDir,
isDebug: config.IsDebug,
encryptionEnv: config.EncryptionEnv,
dataMaxOpenConns: config.DataMaxOpenConns,
dataMaxIdleConns: config.DataMaxIdleConns,
logsMaxOpenConns: config.LogsMaxOpenConns,
logsMaxIdleConns: config.LogsMaxIdleConns,
cache: store.New[any](nil),
store: store.New[any](nil),
settings: settings.New(),
subscriptionsBroker: subscriptions.NewBroker(),
@@ -314,6 +319,17 @@ func (app *BaseApp) IsBootstrapped() bool {
return app.dao != nil && app.logsDao != nil && app.settings != nil
}
// Logger returns the default app logger.
//
// If the application is not bootstrapped yet, fallbacks to slog.Default().
func (app *BaseApp) Logger() *slog.Logger {
if app.logger == nil {
return slog.Default()
}
return app.logger
}
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
@@ -343,6 +359,10 @@ func (app *BaseApp) Bootstrap() error {
return err
}
if err := app.initLogger(); err != nil {
return err
}
// we don't check for an error because the db migrations may have not been executed yet
app.RefreshSettings()
@@ -375,7 +395,6 @@ func (app *BaseApp) ResetBootstrapState() error {
app.dao = nil
app.logsDao = nil
app.settings = nil
return nil
}
@@ -439,20 +458,20 @@ func (app *BaseApp) EncryptionEnv() string {
return app.encryptionEnv
}
// IsDebug returns whether the app is in debug mode
// (showing more detailed error logs, executed sql statements, etc.).
func (app *BaseApp) IsDebug() bool {
return app.isDebug
}
// Settings returns the loaded app settings.
func (app *BaseApp) Settings() *settings.Settings {
return app.settings
}
// Cache returns the app internal cache store.
// Deprecated: Use app.Store() instead.
func (app *BaseApp) Cache() *store.Store[any] {
return app.cache
color.Yellow("app.Store() is soft-deprecated. Please replace it with app.Store().")
return app.Store()
}
// Store returns the app internal runtime store.
func (app *BaseApp) Store() *store.Store[any] {
return app.store
}
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
@@ -471,6 +490,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
Password: app.Settings().Smtp.Password,
Tls: app.Settings().Smtp.Tls,
AuthMethod: app.Settings().Smtp.AuthMethod,
LocalName: app.Settings().Smtp.LocalName,
}
}
@@ -533,17 +553,17 @@ func (app *BaseApp) Restart() error {
return err
}
// optimistically reset the app bootstrap state
app.ResetBootstrapState()
return app.OnTerminate().Trigger(&TerminateEvent{
App: app,
IsRestart: true,
}, func(e *TerminateEvent) error {
e.App.ResetBootstrapState()
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
// restart the app bootstrap state
app.Bootstrap()
// attempt to restart the bootstrap process in case execve returns an error for some reason
defer e.App.Bootstrap()
return err
}
return nil
return syscall.Exec(execPath, os.Args, os.Environ())
})
}
// RefreshSettings reinitializes and reloads the stored application settings.
@@ -555,7 +575,7 @@ func (app *BaseApp) RefreshSettings() error {
encryptionKey := os.Getenv(app.EncryptionEnv())
storedSettings, err := app.Dao().FindSettings(encryptionKey)
if err != nil && err != sql.ErrNoRows {
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
@@ -569,6 +589,11 @@ func (app *BaseApp) RefreshSettings() error {
return err
}
// reload handler level (if initialized)
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
h.SetLevel(slog.Level(app.settings.Logs.MinLevel))
}
return nil
}
@@ -988,7 +1013,7 @@ func (app *BaseApp) initLogsDB() error {
}
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
if err != nil {
@@ -996,7 +1021,7 @@ func (app *BaseApp) initLogsDB() error {
}
nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(1)
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
@@ -1019,7 +1044,7 @@ func (app *BaseApp) initDataDB() error {
}
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
if err != nil {
@@ -1027,19 +1052,17 @@ func (app *BaseApp) initDataDB() error {
}
nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(1)
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
if app.IsDebug() {
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
}
// @todo benchmark whether it will have an impact if always enabled as TRACE log
// nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
// color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
// }
// concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
// nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
// color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
// }
// concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
app.dao = app.createDaoWithHooks(concurrentDB, nonconcurrentDB)
@@ -1129,14 +1152,13 @@ func (app *BaseApp) registerDefaultHooks() {
// run in the background for "optimistic" delete to avoid
// blocking the delete transaction
//
// @todo consider creating a bg process queue so that the
// call could be "retried" in case of a failure.
routine.FireAndForget(func() {
if err := deletePrefix(prefix); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
}
})
}
@@ -1144,12 +1166,95 @@ func (app *BaseApp) registerDefaultHooks() {
return nil
})
app.OnTerminate().Add(func(e *TerminateEvent) error {
app.ResetBootstrapState()
if err := app.initAutobackupHooks(); err != nil {
app.Logger().Error("Failed to init auto backup hooks", slog.String("error", err.Error()))
}
}
func (app *BaseApp) initLogger() error {
duration := 3 * time.Second
ticker := time.NewTicker(duration)
done := make(chan bool)
level := slog.LevelInfo
if app.Settings() != nil {
level = slog.Level(app.Settings().Logs.MinLevel)
}
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: level,
BatchSize: 200,
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
ticker.Reset(duration)
return app.Settings().Logs.MaxDays > 0
},
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
return nil
}
// write the accumulated logs
// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
app.LogsDao().RunInTransaction(func(txDao *daos.Dao) error {
model := &models.Log{}
for _, l := range logs {
model.MarkAsNew()
// note: using pseudorandom for a slightly better performance
model.Id = security.PseudorandomStringWithAlphabet(models.DefaultIdLength, models.DefaultIdAlphabet)
model.Level = int(l.Level)
model.Message = l.Message
model.Data = l.Data
model.Created, _ = types.ParseDateTime(l.Time)
model.Updated = model.Created
if err := txDao.SaveLog(model); err != nil {
log.Println("Failed to write log", model, err)
}
}
return nil
})
// delete old logs
// ---
logsMaxDays := app.Settings().Logs.MaxDays
now := time.Now()
lastLogsDeletedAt := cast.ToTime(app.Store().Get("lastLogsDeletedAt"))
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
if daysDiff > float64(logsMaxDays) {
deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*logsMaxDays))
if deleteErr == nil {
app.Store().Set("lastLogsDeletedAt", now)
} else {
log.Println("Logs delete failed", deleteErr)
}
}
return nil
},
})
go func() {
ctx := context.Background()
for {
select {
case <-done:
handler.WriteAll(ctx)
case <-ticker.C:
handler.WriteAll(ctx)
}
}
}()
app.logger = slog.New(handler)
app.OnTerminate().PreAdd(func(e *TerminateEvent) error {
ticker.Stop()
done <- true
return nil
})
if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
log.Println(err)
}
return nil
}
+83 -50
View File
@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"os"
"path/filepath"
"runtime"
@@ -17,12 +17,16 @@ import (
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/pocketbase/pocketbase/tools/security"
)
// Deprecated: Replaced with StoreKeyActiveBackup.
const CacheKeyActiveBackup string = "@activeBackup"
const StoreKeyActiveBackup string = "@activeBackup"
// CreateBackup creates a new backup of the current app pb_data directory.
//
// If name is empty, it will be autogenerated.
@@ -42,31 +46,35 @@ const CacheKeyActiveBackup string = "@activeBackup"
//
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
if app.Cache().Has(CacheKeyActiveBackup) {
if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
// auto generate backup name
if name == "" {
name = fmt.Sprintf(
"pb_backup_%s.zip",
time.Now().UTC().Format("20060102150405"),
)
name = app.generateBackupName("pb_backup_")
}
app.Cache().Set(CacheKeyActiveBackup, name)
defer app.Cache().Remove(CacheKeyActiveBackup)
app.Store().Set(StoreKeyActiveBackup, name)
defer app.Store().Remove(StoreKeyActiveBackup)
// Archive pb_data in a temp directory, exluding the "backups" dir itself (if exist).
// root dir entries to exclude from the backup generation
exclude := []string{LocalBackupsDirName, LocalTempDirName}
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// Archive pb_data in a temp directory, exluding the "backups" and the temp dirs.
//
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
// ---
tempPath := filepath.Join(os.TempDir(), "pb_backup_"+security.PseudorandomString(4))
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
if err := archive.Create(app.DataDir(), tempPath, LocalBackupsDirName); err != nil {
return err
}
return nil
// @todo consider experimenting with temp switching the readonly pragma after the db interface change
return archive.Create(app.DataDir(), tempPath, exclude...)
})
if createErr != nil {
return createErr
@@ -130,12 +138,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return errors.New("restore is not supported on windows")
}
if app.Cache().Has(CacheKeyActiveBackup) {
if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
app.Cache().Set(CacheKeyActiveBackup, name)
defer app.Cache().Remove(CacheKeyActiveBackup)
app.Store().Set(StoreKeyActiveBackup, name)
defer app.Store().Remove(StoreKeyActiveBackup)
fsys, err := app.NewBackupsFilesystem()
if err != nil {
@@ -152,7 +160,15 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
}
defer br.Close()
tempZip, err := os.CreateTemp(os.TempDir(), "pb_restore")
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// create a temp zip file from the blob.Reader and try to extract it
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
if err != nil {
return err
}
@@ -162,13 +178,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return err
}
// make sure that the special temp directory
if err := os.MkdirAll(filepath.Join(app.DataDir(), LocalTempDirName), os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
extractedDataDir := filepath.Join(app.DataDir(), LocalTempDirName, "pb_restore_"+security.PseudorandomString(4))
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
defer os.RemoveAll(extractedDataDir)
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
return err
@@ -182,8 +192,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// remove the extracted zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() {
log.Println(err)
if err := os.Remove(tempZip.Name()); err != nil {
app.Logger().Debug(
"[RestoreBackup] Failed to remove the temp zip backup file",
slog.String("file", tempZip.Name()),
slog.String("error", err.Error()),
)
}
// root dir entries to exclude from the backup restore
@@ -192,7 +206,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(app.DataDir(), LocalTempDirName, "old_pb_data_"+security.PseudorandomString(4))
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
if err := osutils.MoveDirContent(app.DataDir(), oldTempDataDir, exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
@@ -216,8 +230,8 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// restart the app
if err := app.Restart(); err != nil {
if err := revertDataDirChanges(); err != nil {
panic(err)
if revertErr := revertDataDirChanges(); revertErr != nil {
panic(revertErr)
}
return fmt.Errorf("failed to restart the app process: %w", err)
@@ -227,7 +241,6 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
}
// initAutobackupHooks registers the autobackup app serve hooks.
// @todo add tests
func (app *BaseApp) initAutobackupHooks() error {
c := cron.New()
isServe := false
@@ -241,17 +254,16 @@ func (app *BaseApp) initAutobackupHooks() error {
}
c.Add("@autobackup", rawSchedule, func() {
autoPrefix := "@auto_pb_backup_"
const autoPrefix = "@auto_pb_backup_"
name := fmt.Sprintf(
"%s%s.zip",
autoPrefix,
time.Now().UTC().Format("20060102150405"),
)
name := app.generateBackupName(autoPrefix)
if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
if err := app.CreateBackup(context.Background(), name); err != nil {
app.Logger().Debug(
"[Backup cron] Failed to create backup",
slog.String("name", name),
slog.String("error", err.Error()),
)
}
maxKeep := app.Settings().Backups.CronMaxKeep
@@ -261,17 +273,21 @@ func (app *BaseApp) initAutobackupHooks() error {
}
fsys, err := app.NewBackupsFilesystem()
if err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
if err != nil {
app.Logger().Debug(
"[Backup cron] Failed to initialize the backup filesystem",
slog.String("error", err.Error()),
)
return
}
defer fsys.Close()
files, err := fsys.List(autoPrefix)
if err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
if err != nil {
app.Logger().Debug(
"[Backup cron] Failed to list autogenerated backups",
slog.String("error", err.Error()),
)
return
}
@@ -288,9 +304,12 @@ func (app *BaseApp) initAutobackupHooks() error {
toRemove := files[maxKeep:]
for _, f := range toRemove {
if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
if err := fsys.Delete(f.Key); err != nil {
app.Logger().Debug(
"[Backup cron] Failed to remove old autogenerated backup",
slog.String("key", f.Key),
slog.String("error", err.Error()),
)
}
}
})
@@ -326,3 +345,17 @@ func (app *BaseApp) initAutobackupHooks() error {
return nil
}
func (app *BaseApp) generateBackupName(prefix string) string {
appName := inflector.Snakecase(app.Settings().Meta.AppName)
if len(appName) > 50 {
appName = appName[:50]
}
return fmt.Sprintf(
"%s%s_%s.zip",
prefix,
appName,
time.Now().UTC().Format("20060102150405"),
)
}
+11 -6
View File
@@ -19,12 +19,17 @@ func TestCreateBackup(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// set some long app name with spaces and special characters
app.Settings().Meta.AppName = "test @! " + strings.Repeat("a", 100)
expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
// test pending error
app.Cache().Set(core.CacheKeyActiveBackup, "")
app.Store().Set(core.StoreKeyActiveBackup, "")
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
t.Fatal("Expected pending error, got nil")
}
app.Cache().Remove(core.CacheKeyActiveBackup)
app.Store().Remove(core.StoreKeyActiveBackup)
// create with auto generated name
if err := app.CreateBackup(context.Background(), ""); err != nil {
@@ -49,8 +54,8 @@ func TestCreateBackup(t *testing.T) {
}
expectedFiles := []string{
`^pb_backup_\w+\.zip$`,
`^pb_backup_\w+\.zip.attrs$`,
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
"custom",
"custom.attrs",
}
@@ -93,11 +98,11 @@ func TestRestoreBackup(t *testing.T) {
}
// test pending error
app.Cache().Set(core.CacheKeyActiveBackup, "")
app.Store().Set(core.StoreKeyActiveBackup, "")
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
t.Fatal("Expected pending error, got nil")
}
app.Cache().Remove(core.CacheKeyActiveBackup)
app.Store().Remove(core.StoreKeyActiveBackup)
// missing backup
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
+1 -1
View File
@@ -20,7 +20,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
// check if the new settings are saved in the db
app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil {
t.Fatal("Failed to refresh the settings after delete")
t.Fatalf("Failed to refresh the settings after delete: %v", err)
}
testEventCalls(t, app, map[string]int{
"OnModelBeforeCreate": 1,
+100 -23
View File
@@ -3,8 +3,12 @@ package core
import (
"os"
"testing"
"time"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/migrate"
)
func TestNewBaseApp(t *testing.T) {
@@ -14,7 +18,6 @@ func TestNewBaseApp(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "test_env",
IsDebug: true,
})
if app.dataDir != testDataDir {
@@ -25,12 +28,8 @@ func TestNewBaseApp(t *testing.T) {
t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir)
}
if !app.isDebug {
t.Fatalf("expected isDebug true, got %v", app.isDebug)
}
if app.cache == nil {
t.Fatal("expected cache to be set, got nil")
if app.store == nil {
t.Fatal("expected store to be set, got nil")
}
if app.settings == nil {
@@ -49,7 +48,6 @@ func TestBaseAppBootstrap(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
defer app.ResetBootstrapState()
@@ -57,7 +55,6 @@ func TestBaseAppBootstrap(t *testing.T) {
t.Fatal("Didn't expect the application to be bootstrapped.")
}
// bootstrap
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
@@ -106,6 +103,14 @@ func TestBaseAppBootstrap(t *testing.T) {
t.Fatal("Expected app.settings to be initialized, got nil.")
}
if app.logger == nil {
t.Fatal("Expected app.logger to be initialized, got nil.")
}
if _, ok := app.logger.Handler().(*logger.BatchHandler); !ok {
t.Fatal("Expected app.logger handler to be initialized.")
}
// reset
if err := app.ResetBootstrapState(); err != nil {
t.Fatal(err)
@@ -118,10 +123,6 @@ func TestBaseAppBootstrap(t *testing.T) {
if app.logsDao != nil {
t.Fatalf("Expected app.logsDao to be nil, got %v.", app.logsDao)
}
if app.settings != nil {
t.Fatalf("Expected app.settings to be nil, got %v.", app.settings)
}
}
func TestBaseAppGetters(t *testing.T) {
@@ -131,7 +132,6 @@ func TestBaseAppGetters(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
defer app.ResetBootstrapState()
@@ -163,16 +163,16 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv)
}
if app.isDebug != app.IsDebug() {
t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug)
}
if app.settings != app.Settings() {
t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
}
if app.cache != app.Cache() {
t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache)
if app.store != app.Store() {
t.Fatalf("Expected app.Store %v, got %v", app.Store(), app.store)
}
if app.logger != app.Logger() {
t.Fatalf("Expected app.Logger %v, got %v", app.Logger(), app.logger)
}
if app.subscriptionsBroker != app.SubscriptionsBroker() {
@@ -191,7 +191,6 @@ func TestBaseAppNewMailClient(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
client1 := app.NewMailClient()
@@ -214,7 +213,6 @@ func TestBaseAppNewFilesystem(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local
@@ -244,7 +242,6 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local
@@ -266,3 +263,83 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
}
}
func TestBaseAppLoggerWrites(t *testing.T) {
testDataDir, err := os.MkdirTemp("", "logger_writes")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(testDataDir)
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
// init logs migrations
runner, err := migrate.NewRunner(app.LogsDB(), logs.LogsMigrations)
if err != nil {
t.Fatalf("Logs runner error: %v", err)
}
if _, err := runner.Up(); err != nil {
t.Fatalf("Logs migration execution error: %v", err)
}
threshold := 200
// disabled logs retention
{
app.Settings().Logs.MaxDays = 0
for i := 0; i < threshold+1; i++ {
app.Logger().Error("test")
}
if total := totalLogs(app, t); total != 0 {
t.Fatalf("Expected no logs, got %d", total)
}
}
// test batch logs writes
{
app.Settings().Logs.MaxDays = 1
for i := 0; i < threshold-1; i++ {
app.Logger().Error("test")
}
if total := totalLogs(app, t); total != 0 {
t.Fatalf("Expected no logs, got %d", total)
}
// should trigger batch write
app.Logger().Error("test")
// should be added for the next batch write
app.Logger().Error("test")
if total := totalLogs(app, t); total != threshold {
t.Fatalf("Expected %d logs, got %d", threshold, total)
}
// wait for ~3 secs to check the timer trigger
time.Sleep(3200 * time.Millisecond)
if total := totalLogs(app, t); total != threshold+1 {
t.Fatalf("Expected %d logs, got %d", threshold+1, total)
}
}
}
func totalLogs(app App, t *testing.T) int {
var total int
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
if err != nil {
t.Fatalf("Failed to fetch total logs: %v", err)
}
return total
}
+2 -1
View File
@@ -70,7 +70,8 @@ type BootstrapEvent struct {
}
type TerminateEvent struct {
App App
App App
IsRestart bool
}
type ServeEvent struct {
+1 -8
View File
@@ -6,7 +6,6 @@ package daos
import (
"errors"
"fmt"
"strings"
"time"
"github.com/pocketbase/dbx"
@@ -212,13 +211,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
}
}
if len(errs) > 0 {
// @todo after go 1.20+ upgrade consider replacing with errors.Join()
var errsMsg strings.Builder
for _, err := range errs {
errsMsg.WriteString(err.Error())
errsMsg.WriteString("; ")
}
return fmt.Errorf("after transaction errors: %s", errsMsg.String())
return fmt.Errorf("after transaction errors: %w", errors.Join(errs...))
}
return nil
+9 -1
View File
@@ -2,6 +2,9 @@ package daos
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
@@ -23,9 +26,14 @@ func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
q.WithContext(cancelCtx)
}
return baseLockRetry(func(attempt int) error {
execErr := baseLockRetry(func(attempt int) error {
return op()
}, maxRetries)
if execErr != nil && !errors.Is(execErr, sql.ErrNoRows) {
execErr = fmt.Errorf("%w; failed query: %s", execErr, q.SQL())
}
return execErr
}
}
+54
View File
@@ -378,6 +378,12 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
}
}
// wrap view query if necessary
query, err = txDao.normalizeViewQueryId(query)
if err != nil {
return fmt.Errorf("failed to normalize view query id: %w", err)
}
// (re)create the view
if err := txDao.SaveView(newCollection.Name, query); err != nil {
return err
@@ -389,6 +395,54 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
})
}
// @todo consider removing once custom id types are supported
//
// normalizeViewQueryId wraps (if necessary) the provided view query
// with a subselect to ensure that the id column is a text since
// currently we don't support non-string model ids
// (see https://github.com/pocketbase/pocketbase/issues/3110).
func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
query = strings.Trim(strings.TrimSpace(query), ";")
parsed, err := dao.parseQueryToFields(query)
if err != nil {
return "", err
}
needWrapping := true
idField := parsed[schema.FieldNameId]
if idField != nil && idField.field != nil &&
idField.field.Type != schema.FieldTypeJson &&
idField.field.Type != schema.FieldTypeNumber &&
idField.field.Type != schema.FieldTypeBool {
needWrapping = false
}
if !needWrapping {
return query, nil // no changes needed
}
// raw parse to preserve the columns order
rawParsed := new(identifiersParser)
if err := rawParsed.parse(query); err != nil {
return "", err
}
columns := make([]string, 0, len(rawParsed.columns))
for _, col := range rawParsed.columns {
if col.alias == schema.FieldNameId {
columns = append(columns, fmt.Sprintf("cast([[%s]] as text) [[%s]]", col.alias, col.alias))
} else {
columns = append(columns, "[["+col.alias+"]]")
}
}
query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
return query, nil
}
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
+113 -3
View File
@@ -210,11 +210,12 @@ func TestDeleteCollection(t *testing.T) {
{colUnsaved, true},
{colReferenced, true},
{colSystem, true},
{colBase, true}, // depend on view1, view2 and view2
{colView1, true}, // view2 depend on it
{colView2, false},
{colView1, false}, // no longer has dependent collections
{colBase, false},
{colAuth, false}, // should delete also its related external auths
{colBase, false}, // no longer has dependent views
{colAuth, false}, // should delete also its related external auths
}
for i, s := range scenarios {
@@ -393,8 +394,117 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
}
}
func TestSaveCollectionViewWrapping(t *testing.T) {
viewName := "test_wrapping"
scenarios := []struct {
name string
query string
expected string
}{
{
"no wrapping - text field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
},
{
"no wrapping - id field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
},
{
"no wrapping - relation field",
"select rel_one as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
},
{
"no wrapping - select field",
"select select_many as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
},
{
"no wrapping - email field",
"select email as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
},
{
"no wrapping - datetime field",
"select datetime as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
},
{
"no wrapping - url field",
"select url as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
},
{
"wrapping - bool field",
"select bool as id, text as txt, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
},
{
"wrapping - bool field (different order)",
"select text as txt, url, bool as id from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,cast(`id` as text) `id` FROM (select text as txt, url, bool as id from demo1))",
},
{
"wrapping - json field",
"select json as id, text, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
},
{
"wrapping - numeric id",
"select 1 as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select 1 as id))",
},
{
"wrapping - expresion",
"select ('test') as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select ('test') as id))",
},
{
"no wrapping - cast as text",
"select cast('test' as text) as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := &models.Collection{
Name: viewName,
Type: models.CollectionTypeView,
Options: types.JsonMap{
"query": s.query,
},
}
err := app.Dao().SaveCollection(collection)
if err != nil {
t.Fatal(err)
}
var sql string
rowErr := app.Dao().DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
Bind(dbx.Params{"name": viewName}).
Row(&sql)
if rowErr != nil {
t.Fatalf("Failed to retrieve view sql: %v", rowErr)
}
if sql != s.expected {
t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
}
})
}
}
func TestImportCollections(t *testing.T) {
totalCollections := 10
totalCollections := 11
scenarios := []struct {
name string
+18 -21
View File
@@ -32,27 +32,6 @@ func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*mode
return auths, nil
}
// FindExternalAuthByProvider returns the first available
// ExternalAuth model for the specified provider and providerId.
func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
model := &models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
AndWhere(dbx.HashExp{
"provider": provider,
"providerId": providerId,
}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
// FindExternalAuthByRecordAndProvider returns the first available
// ExternalAuth model for the specified record data and provider.
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
@@ -74,6 +53,24 @@ func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, p
return model, nil
}
// FindFirstExternalAuthByExpr returns the first available
// ExternalAuth model that satisfies the non-nil expression.
func (dao *Dao) FindFirstExternalAuthByExpr(expr dbx.Expression) (*models.ExternalAuth, error) {
model := &models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
AndWhere(expr).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
// SaveExternalAuth upserts the provided ExternalAuth model.
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
// extra check the model data in case the provider's API response
+14 -11
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -56,25 +57,23 @@ func TestFindAllExternalAuthsByRecord(t *testing.T) {
}
}
func TestFindExternalAuthByProvider(t *testing.T) {
func TestFindFirstExternalAuthByExpr(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
provider string
providerId string
expr dbx.Expression
expectedId string
}{
{"", "", ""},
{"github", "", ""},
{"github", "id1", ""},
{"github", "id2", ""},
{"google", "test123", "clmflokuq1xl341"},
{"gitlab", "test123", "dlmflokuq1xl342"},
{dbx.HashExp{"provider": "github", "providerId": ""}, ""},
{dbx.HashExp{"provider": "github", "providerId": "id1"}, ""},
{dbx.HashExp{"provider": "github", "providerId": "id2"}, ""},
{dbx.HashExp{"provider": "google", "providerId": "test123"}, "clmflokuq1xl341"},
{dbx.HashExp{"provider": "gitlab", "providerId": "test123"}, "dlmflokuq1xl342"},
}
for i, s := range scenarios {
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
auth, err := app.Dao().FindFirstExternalAuthByExpr(s.expr)
hasErr := err != nil
expectErr := s.expectedId == ""
@@ -147,7 +146,11 @@ func TestSaveExternalAuth(t *testing.T) {
}
// check if it was really saved
foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
foundAuth, err := app.Dao().FindFirstExternalAuthByExpr(dbx.HashExp{
"collectionId": "v851q4r790rhknl",
"provider": "test",
"providerId": "test_id",
})
if err != nil {
t.Fatal(err)
}
+67
View File
@@ -0,0 +1,67 @@
package daos
import (
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
// LogQuery returns a new Log select query.
func (dao *Dao) LogQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Log{})
}
// FindLogById finds a single Log entry by its id.
func (dao *Dao) FindLogById(id string) (*models.Log, error) {
model := &models.Log{}
err := dao.LogQuery().
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
type LogsStatsItem struct {
Total int `db:"total" json:"total"`
Date types.DateTime `db:"date" json:"date"`
}
// LogsStats returns hourly grouped requests logs statistics.
func (dao *Dao) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
result := []*LogsStatsItem{}
query := dao.LogQuery().
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
GroupBy("date")
if expr != nil {
query.AndWhere(expr)
}
err := query.All(&result)
return result, err
}
// DeleteOldLogs delete all requests that are created before createdBefore.
func (dao *Dao) DeleteOldLogs(createdBefore time.Time) error {
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
_, err := dao.NonconcurrentDB().Delete((&models.Log{}).TableName(), expr).Execute()
return err
}
// SaveLog upserts the provided Log model.
func (dao *Dao) SaveLog(log *models.Log) error {
return dao.Save(log)
}
+32 -32
View File
@@ -11,23 +11,23 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRequestQuery(t *testing.T) {
func TestLogQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
expected := "SELECT {{_requests}}.* FROM `_requests`"
expected := "SELECT {{_logs}}.* FROM `_logs`"
sql := app.Dao().RequestQuery().Build().SQL()
sql := app.Dao().LogQuery().Build().SQL()
if sql != expected {
t.Errorf("Expected sql %s, got %s", expected, sql)
}
}
func TestFindRequestById(t *testing.T) {
func TestFindLogById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
tests.MockRequestLogsData(app)
tests.MockLogsData(app)
scenarios := []struct {
id string
@@ -40,7 +40,7 @@ func TestFindRequestById(t *testing.T) {
}
for i, scenario := range scenarios {
admin, err := app.LogsDao().FindRequestById(scenario.id)
admin, err := app.LogsDao().FindLogById(scenario.id)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -53,17 +53,17 @@ func TestFindRequestById(t *testing.T) {
}
}
func TestRequestsStats(t *testing.T) {
func TestLogsStats(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
tests.MockRequestLogsData(app)
tests.MockLogsData(app)
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
now := time.Now().UTC().Format(types.DefaultDateLayout)
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
result, err := app.LogsDao().RequestsStats(exp)
result, err := app.LogsDao().LogsStats(exp)
if err != nil {
t.Fatal(err)
}
@@ -74,20 +74,20 @@ func TestRequestsStats(t *testing.T) {
}
}
func TestDeleteOldRequests(t *testing.T) {
func TestDeleteOldLogs(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
tests.MockRequestLogsData(app)
tests.MockLogsData(app)
scenarios := []struct {
date string
expectedTotal int
}{
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
{"2022-01-01 10:00:00.000Z", 2}, // no logs to delete before that time
{"2022-05-01 11:00:00.000Z", 1}, // only 1 log should have left
{"2022-05-03 11:00:00.000Z", 0}, // no more logs should have left
{"2022-05-04 11:00:00.000Z", 0}, // no more logs should have left
}
for i, scenario := range scenarios {
@@ -96,53 +96,53 @@ func TestDeleteOldRequests(t *testing.T) {
t.Errorf("(%d) Date error %v", i, dateErr)
}
deleteErr := app.LogsDao().DeleteOldRequests(date)
deleteErr := app.LogsDao().DeleteOldLogs(date)
if deleteErr != nil {
t.Errorf("(%d) Delete error %v", i, deleteErr)
}
// check total remaining requests
// check total remaining logs
var total int
countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total)
countErr := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
if countErr != nil {
t.Errorf("(%d) Count error %v", i, countErr)
}
if total != scenario.expectedTotal {
t.Errorf("(%d) Expected %d remaining requests, got %d", i, scenario.expectedTotal, total)
t.Errorf("(%d) Expected %d remaining logs, got %d", i, scenario.expectedTotal, total)
}
}
}
func TestSaveRequest(t *testing.T) {
func TestSaveLog(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
tests.MockRequestLogsData(app)
tests.MockLogsData(app)
// create new request
newRequest := &models.Request{}
newRequest.Method = "get"
newRequest.Meta = types.JsonMap{}
createErr := app.LogsDao().SaveRequest(newRequest)
// create new log
newLog := &models.Log{}
newLog.Level = -4
newLog.Data = types.JsonMap{}
createErr := app.LogsDao().SaveLog(newLog)
if createErr != nil {
t.Fatal(createErr)
}
// check if it was really created
existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id)
existingLog, fetchErr := app.LogsDao().FindLogById(newLog.Id)
if fetchErr != nil {
t.Fatal(fetchErr)
}
existingRequest.Method = "post"
updateErr := app.LogsDao().SaveRequest(existingRequest)
existingLog.Level = 4
updateErr := app.LogsDao().SaveLog(existingLog)
if updateErr != nil {
t.Fatal(updateErr)
}
// refresh instance to check if it was really updated
existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id)
if existingRequest.Method != "post" {
t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method)
existingLog, _ = app.LogsDao().FindLogById(existingLog.Id)
if existingLog.Level != 4 {
t.Fatalf("Expected log level to be %d, got %d", 4, existingLog.Level)
}
}
+25 -10
View File
@@ -252,23 +252,31 @@ func (dao *Dao) FindFirstRecordByData(
// FindRecordsByFilter returns limit number of records matching the
// provided string filter.
//
// NB! Use the last "params" argument to bind untrusted user variables!
//
// The sort argument is optional and can be empty string OR the same format
// used in the web APIs, eg. "-created,title".
//
// If the limit argument is <= 0, no limit is applied to the query and
// all matching records are returned.
//
// NB! Don't put untrusted user input in the filter string as it
// practically would allow the users to inject their own custom filter.
//
// Example:
//
// dao.FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
// dao.FindRecordsByFilter(
// "posts",
// "title ~ {:title} && visible = {:visible}",
// "-created",
// 10,
// 0,
// dbx.Params{"title": "lorem ipsum", "visible": true}
// )
func (dao *Dao) FindRecordsByFilter(
collectionNameOrId string,
filter string,
sort string,
limit int,
offset int,
params ...dbx.Params,
) ([]*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
@@ -286,7 +294,7 @@ func (dao *Dao) FindRecordsByFilter(
true, // allow searching hidden/protected fields like "email"
)
expr, err := search.FilterData(filter).BuildExpr(resolver)
expr, err := search.FilterData(filter).BuildExpr(resolver, params...)
if err != nil || expr == nil {
return nil, errors.New("invalid or empty filter expression")
}
@@ -307,6 +315,10 @@ func (dao *Dao) FindRecordsByFilter(
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
// ---
if offset > 0 {
q.Offset(int64(offset))
}
if limit > 0 {
q.Limit(int64(limit))
}
@@ -322,14 +334,17 @@ func (dao *Dao) FindRecordsByFilter(
// FindFirstRecordByFilter returns the first available record matching the provided filter.
//
// NB! Don't put untrusted user input in the filter string as it
// practically would allow the users to inject their own custom filter.
// NB! Use the last params argument to bind untrusted user variables!
//
// Example:
//
// dao.FindFirstRecordByFilter("posts", "slug='test'")
func (dao *Dao) FindFirstRecordByFilter(collectionNameOrId string, filter string) (*models.Record, error) {
result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1)
// dao.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
func (dao *Dao) FindFirstRecordByFilter(
collectionNameOrId string,
filter string,
params ...dbx.Params,
) (*models.Record, error) {
result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1, 0, params...)
if err != nil {
return nil, err
}
-49
View File
@@ -10,7 +10,6 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -159,10 +158,6 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
return err
}
if err := txDao.syncRelationDisplayFieldsChanges(newCollection, renamedFieldNames, deletedFieldNames); err != nil {
return err
}
return txDao.createCollectionIndexes(newCollection)
})
}
@@ -294,50 +289,6 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
})
}
func (dao *Dao) syncRelationDisplayFieldsChanges(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
return nil // nothing to sync
}
refs, err := dao.FindCollectionReferences(collection)
if err != nil {
return err
}
for refCollection, refFields := range refs {
for _, refField := range refFields {
options, _ := refField.Options.(*schema.RelationOptions)
if options == nil {
continue
}
// remove deleted (if any)
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
for old, new := range renamedFieldNames {
for i, name := range newDisplayFields {
if name == old {
newDisplayFields[i] = new
}
}
}
// has changes
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
options.DisplayFields = newDisplayFields
// direct collection save to prevent self-referencing
// recursion and unnecessary records table sync checks
if err := dao.Save(refCollection); err != nil {
return err
}
}
}
}
return nil
}
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
+71 -28
View File
@@ -436,6 +436,8 @@ func TestFindRecordsByFilter(t *testing.T) {
filter string
sort string
limit int
offset int
params []dbx.Params
expectError bool
expectRecordIds []string
}{
@@ -445,6 +447,8 @@ func TestFindRecordsByFilter(t *testing.T) {
"id != ''",
"",
0,
0,
nil,
true,
nil,
},
@@ -454,6 +458,8 @@ func TestFindRecordsByFilter(t *testing.T) {
"",
"",
0,
0,
nil,
true,
nil,
},
@@ -463,6 +469,8 @@ func TestFindRecordsByFilter(t *testing.T) {
"someMissingField > 1",
"",
0,
0,
nil,
true,
nil,
},
@@ -472,6 +480,8 @@ func TestFindRecordsByFilter(t *testing.T) {
"id != ''",
"",
0,
0,
nil,
false,
[]string{
"llvuca81nly1qls",
@@ -485,6 +495,8 @@ func TestFindRecordsByFilter(t *testing.T) {
"id != '' && active=true",
"-created,title",
-1, // should behave the same as 0
0,
nil,
false,
[]string{
"0yxhwia2amd8gec",
@@ -492,47 +504,64 @@ func TestFindRecordsByFilter(t *testing.T) {
},
},
{
"with limit",
"with limit and offset",
"demo2",
"id != ''",
"title",
2,
1,
nil,
false,
[]string{
"achvryl401bhse3",
"0yxhwia2amd8gec",
},
},
{
"with placeholder params",
"demo2",
"active = {:active}",
"",
10,
0,
[]dbx.Params{{"active": false}},
false,
[]string{
"llvuca81nly1qls",
"achvryl401bhse3",
},
},
}
for _, s := range scenarios {
records, err := app.Dao().FindRecordsByFilter(
s.collectionIdOrName,
s.filter,
s.sort,
s.limit,
)
t.Run(s.name, func(t *testing.T) {
records, err := app.Dao().FindRecordsByFilter(
s.collectionIdOrName,
s.filter,
s.sort,
s.limit,
s.offset,
s.params...,
)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if len(records) != len(s.expectRecordIds) {
t.Errorf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
continue
}
for i, id := range s.expectRecordIds {
if id != records[i].Id {
t.Errorf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
}
if hasErr {
return
}
if len(records) != len(s.expectRecordIds) {
t.Fatalf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
}
for i, id := range s.expectRecordIds {
if id != records[i].Id {
t.Fatalf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
}
}
})
}
}
@@ -544,6 +573,7 @@ func TestFindFirstRecordByFilter(t *testing.T) {
name string
collectionIdOrName string
filter string
params []dbx.Params
expectError bool
expectRecordId string
}{
@@ -551,6 +581,7 @@ func TestFindFirstRecordByFilter(t *testing.T) {
"missing collection",
"missing",
"id != ''",
nil,
true,
"",
},
@@ -558,6 +589,7 @@ func TestFindFirstRecordByFilter(t *testing.T) {
"missing filter",
"demo2",
"",
nil,
true,
"",
},
@@ -565,6 +597,7 @@ func TestFindFirstRecordByFilter(t *testing.T) {
"invalid filter",
"demo2",
"someMissingField > 1",
nil,
true,
"",
},
@@ -572,6 +605,7 @@ func TestFindFirstRecordByFilter(t *testing.T) {
"valid filter but no matches",
"demo2",
"id = 'test'",
nil,
true,
"",
},
@@ -579,13 +613,22 @@ func TestFindFirstRecordByFilter(t *testing.T) {
"valid filter and multiple matches",
"demo2",
"id != ''",
nil,
false,
"llvuca81nly1qls",
},
{
"with placeholder params",
"demo2",
"active = {:active}",
[]dbx.Params{{"active": false}},
false,
"llvuca81nly1qls",
},
}
for _, s := range scenarios {
record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter)
record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter, s.params...)
hasErr := err != nil
if hasErr != s.expectError {
-70
View File
@@ -1,70 +0,0 @@
package daos
import (
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
// RequestQuery returns a new Request logs select query.
func (dao *Dao) RequestQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Request{})
}
// FindRequestById finds a single Request log by its id.
func (dao *Dao) FindRequestById(id string) (*models.Request, error) {
model := &models.Request{}
err := dao.RequestQuery().
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
type RequestsStatsItem struct {
Total int `db:"total" json:"total"`
Date types.DateTime `db:"date" json:"date"`
}
// RequestsStats returns hourly grouped requests logs statistics.
func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) {
result := []*RequestsStatsItem{}
query := dao.RequestQuery().
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
GroupBy("date")
if expr != nil {
query.AndWhere(expr)
}
err := query.All(&result)
return result, err
}
// DeleteOldRequests delete all requests that are created before createdBefore.
func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
m := models.Request{}
tableName := m.TableName()
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
_, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
return err
}
// SaveRequest upserts the provided Request model.
func (dao *Dao) SaveRequest(request *models.Request) error {
return dao.Save(request)
}
+53 -16
View File
@@ -43,10 +43,10 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
return err
}
trimmed := strings.Trim(selectQuery, ";")
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
// try to eagerly detect multiple inline statements
tk := tokenizer.NewFromString(trimmed)
tk := tokenizer.NewFromString(selectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
@@ -56,7 +56,7 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
//
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution.
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
return err
}
@@ -232,9 +232,14 @@ func defaultViewField(name string) *schema.SchemaField {
return &schema.SchemaField{
Name: name,
Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 1, // the size doesn't matter in this case
},
}
}
var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
p := new(identifiersParser)
if err := p.parse(selectQuery); err != nil {
@@ -257,15 +262,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
for _, col := range p.columns {
colLower := strings.ToLower(col.original)
// numeric expression cast
if strings.Contains(colLower, "(") &&
(strings.HasPrefix(colLower, "count(") ||
strings.HasPrefix(colLower, "total(") ||
strings.Contains(colLower, " as numeric") ||
strings.Contains(colLower, " as real") ||
strings.Contains(colLower, " as int") ||
strings.Contains(colLower, " as integer") ||
strings.Contains(colLower, " as decimal")) {
// numeric aggregations
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
@@ -275,6 +273,38 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
continue
}
castMatch := castRegex.FindStringSubmatch(colLower)
// numeric casts
if len(castMatch) == 2 {
switch castMatch[1] {
case "real", "integer", "int", "decimal", "numeric":
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeNumber,
},
}
continue
case "text":
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeText,
},
}
continue
case "boolean", "bool":
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeBool,
},
}
continue
}
}
parts := strings.Split(col.original, ".")
var fieldName string
@@ -431,7 +461,7 @@ type identifiersParser struct {
}
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(selectQuery, ";")
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
@@ -572,13 +602,20 @@ func identifierFromParts(parts []string) (identifier, error) {
}
result.original = trimRawIdentifier(result.original)
result.alias = trimRawIdentifier(result.alias)
// we trim the single quote even though it is not a valid column quote character
// because SQLite allows it if the context expects an identifier and not string literal
// (https://www.sqlite.org/lang_keywords.html)
result.alias = trimRawIdentifier(result.alias, "'")
return result, nil
}
func trimRawIdentifier(rawIdentifier string) string {
const trimChars = "`\"[];"
func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
trimChars := "`\"[];"
if len(extraTrimChars) > 0 {
trimChars += strings.Join(extraTrimChars, "")
}
parts := strings.Split(rawIdentifier, ".")
+48 -41
View File
@@ -147,34 +147,33 @@ func TestSaveView(t *testing.T) {
}
for _, s := range scenarios {
err := app.Dao().SaveView(s.viewName, s.query)
t.Run(s.scenarioName, func(t *testing.T) {
err := app.Dao().SaveView(s.viewName, s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
infoRows, err := app.Dao().TableInfo(s.viewName)
if err != nil {
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
continue
}
if len(s.expectColumns) != len(infoRows) {
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
continue
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
}
if hasErr {
return
}
infoRows, err := app.Dao().TableInfo(s.viewName)
if err != nil {
t.Fatalf("Failed to fetch table info for %s: %v", s.viewName, err)
}
if len(s.expectColumns) != len(infoRows) {
t.Fatalf("Expected %d columns, got %d", len(s.expectColumns), len(infoRows))
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Fatalf("Missing %q column in %v", row.Name, s.expectColumns)
}
}
})
}
ensureNoTempViews(app, t)
@@ -272,24 +271,26 @@ func TestCreateViewSchema(t *testing.T) {
"datetime",
"json",
"rel_one",
"rel_many"
"rel_many",
'single_quoted_custom_literal' as 'single_quoted_column'
from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"bool": schema.FieldTypeBool,
"url": schema.FieldTypeUrl,
"select_one": schema.FieldTypeSelect,
"select_many": schema.FieldTypeSelect,
"file_one": schema.FieldTypeFile,
"file_many": schema.FieldTypeFile,
"number_alias": schema.FieldTypeNumber,
"email": schema.FieldTypeEmail,
"datetime": schema.FieldTypeDate,
"json": schema.FieldTypeJson,
"rel_one": schema.FieldTypeRelation,
"rel_many": schema.FieldTypeRelation,
"text": schema.FieldTypeText,
"bool": schema.FieldTypeBool,
"url": schema.FieldTypeUrl,
"select_one": schema.FieldTypeSelect,
"select_many": schema.FieldTypeSelect,
"file_one": schema.FieldTypeFile,
"file_many": schema.FieldTypeFile,
"number_alias": schema.FieldTypeNumber,
"email": schema.FieldTypeEmail,
"datetime": schema.FieldTypeDate,
"json": schema.FieldTypeJson,
"rel_one": schema.FieldTypeRelation,
"rel_many": schema.FieldTypeRelation,
"single_quoted_column": schema.FieldTypeJson,
},
},
{
@@ -330,7 +331,7 @@ func TestCreateViewSchema(t *testing.T) {
},
},
{
"query with numeric casts",
"query with casts",
`select
a.id,
count(a.id) count,
@@ -339,6 +340,9 @@ func TestCreateViewSchema(t *testing.T) {
cast(a.id as real) cast_real,
cast(a.id as decimal) cast_decimal,
cast(a.id as numeric) cast_numeric,
cast(a.id as text) cast_text,
cast(a.id as bool) cast_bool,
cast(a.id as boolean) cast_boolean,
avg(a.id) avg,
sum(a.id) sum,
total(a.id) total,
@@ -354,6 +358,9 @@ func TestCreateViewSchema(t *testing.T) {
"cast_real": schema.FieldTypeNumber,
"cast_decimal": schema.FieldTypeNumber,
"cast_numeric": schema.FieldTypeNumber,
"cast_text": schema.FieldTypeText,
"cast_bool": schema.FieldTypeBool,
"cast_boolean": schema.FieldTypeBool,
// json because they are nullable
"sum": schema.FieldTypeJson,
"avg": schema.FieldTypeJson,
+2 -2
View File
@@ -42,7 +42,7 @@ func main() {
app.RootCmd.PersistentFlags().IntVar(
&hooksPool,
"hooksPool",
50,
25,
"the total prewarm goja.Runtime instances for the JS app hooks execution",
)
@@ -110,7 +110,7 @@ func main() {
// GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
app.OnAfterBootstrap().PreAdd(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
return nil
})
+5 -4
View File
@@ -2,6 +2,7 @@ package forms
import (
"errors"
"fmt"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -66,7 +67,7 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
admin, err := form.dao.FindAdminByEmail(form.Email)
if err != nil {
return err
return fmt.Errorf("Failed to fetch admin with email %s: %w", form.Email, err)
}
now := time.Now().UTC()
@@ -75,14 +76,14 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
return errors.New("You have already requested a password reset.")
}
// update last sent timestamp
admin.LastResetSentAt = types.NowDateTime()
return runInterceptors(admin, func(m *models.Admin) error {
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
return err
}
// update last sent timestamp
m.LastResetSentAt = types.NowDateTime()
return form.dao.SaveAdmin(m)
}, interceptors...)
}
+2 -2
View File
@@ -117,7 +117,7 @@ func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastResetSentAt.String() == admin.LastResetSentAt.String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
if interceptorLastResetSentAt.String() != admin.LastResetSentAt.String() {
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
}
}
+10 -10
View File
@@ -38,7 +38,7 @@ func TestBackupCreateValidateAndSubmit(t *testing.T) {
}
for _, s := range scenarios {
func() {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -56,47 +56,47 @@ func TestBackupCreateValidateAndSubmit(t *testing.T) {
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
t.Fatalf("Failed to parse errors %v", result)
return
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
// retrieve all created backup files
files, err := fsys.List("")
if err != nil {
t.Errorf("[%s] Failed to retrieve backup files", s.name)
t.Fatal("Failed to retrieve backup files")
return
}
if result != nil {
if total := len(files); total != 0 {
t.Errorf("[%s] Didn't expected backup files, found %d", s.name, total)
t.Fatalf("Didn't expected backup files, found %d", total)
}
return
}
if total := len(files); total != 1 {
t.Errorf("[%s] Expected 1 backup file, got %d", s.name, total)
t.Fatalf("Expected 1 backup file, got %d", total)
return
}
if s.backupName == "" {
prefix := "pb_backup_"
if !strings.HasPrefix(files[0].Key, prefix) {
t.Errorf("[%s] Expected the backup file, to have prefix %q: %q", s.name, prefix, files[0].Key)
t.Fatalf("Expected the backup file, to have prefix %q: %q", prefix, files[0].Key)
}
} else if s.backupName != files[0].Key {
t.Errorf("[%s] Expected backup file %q, got %q", s.name, s.backupName, files[0].Key)
t.Fatalf("Expected backup file %q, got %q", s.backupName, files[0].Key)
}
}()
})
}
}
+85
View File
@@ -0,0 +1,85 @@
package forms
import (
"context"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
// BackupUpload is a request form for uploading a new app backup.
type BackupUpload struct {
app core.App
ctx context.Context
File *filesystem.File `json:"file"`
}
// NewBackupUpload creates new BackupUpload request form.
func NewBackupUpload(app core.App) *BackupUpload {
return &BackupUpload{
app: app,
ctx: context.Background(),
}
}
// SetContext replaces the default form upload context with the provided one.
func (form *BackupUpload) SetContext(ctx context.Context) {
form.ctx = ctx
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *BackupUpload) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.File,
validation.Required,
validation.By(validators.UploadedFileMimeType([]string{"application/zip"})),
validation.By(form.checkUniqueName),
),
)
}
func (form *BackupUpload) checkUniqueName(value any) error {
v, _ := value.(*filesystem.File)
if v == nil {
return nil // nothing to check
}
fsys, err := form.app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(form.ctx)
if exists, err := fsys.Exists(v.OriginalName); err != nil || exists {
return validation.NewError("validation_backup_name_exists", "Backup file with the specified name already exists.")
}
return nil
}
// Submit validates the form and upload the backup file.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before uploading the backup.
func (form *BackupUpload) Submit(interceptors ...InterceptorFunc[*filesystem.File]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(form.File, func(file *filesystem.File) error {
fsys, err := form.app.NewBackupsFilesystem()
if err != nil {
return err
}
fsys.SetContext(form.ctx)
return fsys.UploadFile(file, file.OriginalName)
}, interceptors...)
}
+118
View File
@@ -0,0 +1,118 @@
package forms_test
import (
"archive/zip"
"bytes"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
func TestBackupUploadValidateAndSubmit(t *testing.T) {
var zb bytes.Buffer
zw := zip.NewWriter(&zb)
if err := zw.Close(); err != nil {
t.Fatal(err)
}
f0, _ := filesystem.NewFileFromBytes([]byte("test"), "existing")
f1, _ := filesystem.NewFileFromBytes([]byte("456"), "nozip")
f2, _ := filesystem.NewFileFromBytes(zb.Bytes(), "existing")
f3, _ := filesystem.NewFileFromBytes(zb.Bytes(), "zip")
scenarios := []struct {
name string
file *filesystem.File
expectedErrors []string
}{
{
"missing file",
nil,
[]string{"file"},
},
{
"non-zip file",
f1,
[]string{"file"},
},
{
"zip file with non-unique name",
f2,
[]string{"file"},
},
{
"zip file with unique name",
f3,
[]string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
fsys, err := app.NewBackupsFilesystem()
if err != nil {
t.Fatal(err)
}
defer fsys.Close()
// create a dummy backup file to simulate existing backups
if err := fsys.UploadFile(f0, f0.OriginalName); err != nil {
t.Fatal(err)
}
form := forms.NewBackupUpload(app)
form.File = s.file
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
}
// 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)
}
}
expectedFiles := []*filesystem.File{f0}
if result == nil {
expectedFiles = append(expectedFiles, s.file)
}
// retrieve all uploaded backup files
files, err := fsys.List("")
if err != nil {
t.Fatal("Failed to retrieve backup files")
}
if len(files) != len(expectedFiles) {
t.Fatalf("Expected %d files, got %d", len(expectedFiles), len(files))
}
for _, ef := range expectedFiles {
exists := false
for _, f := range files {
if f.Key == ef.OriginalName {
exists = true
break
}
}
if !exists {
t.Fatalf("Missing expected backup file %v", ef.OriginalName)
}
}
})
}
}
+2 -24
View File
@@ -229,14 +229,6 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
func (form *CollectionUpsert) checkRelationFields(value any) error {
v, _ := value.(schema.Schema)
systemDisplayFields := schema.BaseModelFieldNames()
systemDisplayFields = append(systemDisplayFields,
schema.FieldNameUsername,
schema.FieldNameEmail,
schema.FieldNameEmailVisibility,
schema.FieldNameVerified,
)
for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation {
continue
@@ -289,25 +281,11 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_non_view_base_relation_collection",
"Non view collections are now allowed to have a view relation.",
"Non view collections are not allowed to have a view relation.",
),
}},
}
}
// validate displayFields (if any)
for _, name := range options.DisplayFields {
if relCollection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"displayFields": validation.NewError(
"validation_field_invalid_relation_displayFields",
fmt.Sprintf("%q does not exist in the related %q collection.", name, relCollection.Name),
),
}},
}
}
}
}
return nil
@@ -392,7 +370,7 @@ func (form *CollectionUpsert) checkRule(value any) error {
_, err := search.FilterData(*v).BuildExpr(r)
if err != nil {
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
return validation.NewError("validation_invalid_rule", "Invalid filter rule. Raw error: "+err.Error())
}
return nil
+1 -20
View File
@@ -171,25 +171,6 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
{
"create failure - missing relation display field",
"",
`{
"name": "test_new",
"type": "base",
"schema": [
{
"name":"test",
"type":"relation",
"options":{
"collectionId":"wsmn24bux7wo113",
"displayFields":["text", "missing"]
}
}
]
}`,
[]string{"schema"},
},
{
"create failure - check auth options validators",
"",
@@ -605,7 +586,7 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}
if form.Name != collection.Name {
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
t.Fatalf("Expected Name %q, got %q", collection.Name, form.Name)
}
if form.Type != collection.Type {
+1 -5
View File
@@ -3,7 +3,6 @@ package forms
import (
"encoding/json"
"fmt"
"log"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
@@ -78,12 +77,9 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
}
// generic/db failure
if form.app.IsDebug() {
log.Println("Internal import failure:", importErr)
}
return validation.Errors{"collections": validation.NewError(
"collections_import_failure",
"Failed to import the collections configuration.",
"Failed to import the collections configuration. Raw error:\n"+importErr.Error(),
)}
})
}, interceptors...)
+38 -37
View File
@@ -38,7 +38,7 @@ func TestCollectionsImportValidate(t *testing.T) {
}
func TestCollectionsImportSubmit(t *testing.T) {
totalCollections := 10
totalCollections := 11
scenarios := []struct {
name string
@@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 4,
"OnModelBeforeDelete": 1,
},
},
{
@@ -418,44 +418,45 @@ func TestCollectionsImportSubmit(t *testing.T) {
}
for _, s := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
t.Run(s.name, func(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewCollectionsImport(testApp)
form := forms.NewCollectionsImport(testApp)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != s.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
}
// check events
if len(testApp.EventCalls) > len(s.expectEvents) {
t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
}
for event, expectedCalls := range s.expectEvents {
actualCalls := testApp.EventCalls[event]
if actualCalls != expectedCalls {
t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Fatalf("Failed to load form data: %v", loadErr)
}
}
err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != s.expectCollectionsCount {
t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
}
// check events
if len(testApp.EventCalls) > len(s.expectEvents) {
t.Fatalf("Expected events %v, got %v", s.expectEvents, testApp.EventCalls)
}
for event, expectedCalls := range s.expectEvents {
actualCalls := testApp.EventCalls[event]
if actualCalls != expectedCalls {
t.Fatalf("Expected event %s to be called %d, got %d", event, expectedCalls, actualCalls)
}
}
})
}
}
+2
View File
@@ -128,6 +128,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*mo
authRecord.SetEmail(newEmail)
authRecord.SetVerified(true)
// @todo consider removing if not necessary anymore
authRecord.RefreshTokenKey() // invalidate old tokens
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
+1 -1
View File
@@ -54,7 +54,7 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
v, _ := value.(string)
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
return validation.NewError("validation_record_email_exists", "User email already exists.")
return validation.NewError("validation_record_email_invalid", "User email already exists or it is invalid.")
}
return nil
+15 -9
View File
@@ -7,7 +7,7 @@ import (
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
@@ -46,7 +46,7 @@ type RecordOAuth2Login struct {
// The authorization code returned from the initial request.
Code string `form:"code" json:"code"`
// The code verifier sent with the initial request as part of the code_challenge.
// The optional PKCE code verifier as part of the code_challenge sent with the initial request.
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
// The redirect url sent with the initial request.
@@ -88,8 +88,7 @@ func (form *RecordOAuth2Login) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required),
validation.Field(&form.CodeVerifier, validation.Required),
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
validation.Field(&form.RedirectUrl, validation.Required),
)
}
@@ -143,11 +142,14 @@ func (form *RecordOAuth2Login) Submit(
provider.SetRedirectUrl(form.RedirectUrl)
var opts []oauth2.AuthCodeOption
if provider.PKCE() {
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
}
// fetch token
token, err := provider.FetchToken(
form.Code,
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
)
token, err := provider.FetchToken(form.Code, opts...)
if err != nil {
return nil, nil, err
}
@@ -161,7 +163,11 @@ func (form *RecordOAuth2Login) Submit(
var authRecord *models.Record
// check for existing relation with the auth record
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
rel, _ := form.dao.FindFirstExternalAuthByExpr(dbx.HashExp{
"collectionId": form.collection.Id,
"provider": form.Provider,
"providerId": authUser.Id,
})
switch {
case rel != nil:
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
+8 -2
View File
@@ -23,13 +23,13 @@ func TestUserOauth2LoginValidate(t *testing.T) {
"empty payload",
"users",
"{}",
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
[]string{"provider", "code", "redirectUrl"},
},
{
"empty data",
"users",
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
[]string{"provider", "code", "redirectUrl"},
},
{
"missing provider",
@@ -49,6 +49,12 @@ func TestUserOauth2LoginValidate(t *testing.T) {
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{},
},
{
"[#3689] any redirectUrl value",
"users",
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"something"}`,
[]string{},
},
}
for _, s := range scenarios {
+5 -4
View File
@@ -2,6 +2,7 @@ package forms
import (
"errors"
"fmt"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -69,7 +70,7 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
if err != nil {
return err
return fmt.Errorf("Failed to fetch %s record with email %s: %w", form.collection.Id, form.Email, err)
}
now := time.Now().UTC()
@@ -78,14 +79,14 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*
return errors.New("You've already requested a password reset.")
}
// update last sent timestamp
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return runInterceptors(authRecord, func(m *models.Record) error {
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
return err
}
// update last sent timestamp
m.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return form.dao.SaveRecord(m)
}, interceptors...)
}
+2 -2
View File
@@ -164,7 +164,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
if interceptorLastResetSentAt.String() != authRecord.LastResetSentAt().String() {
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
}
}
+27 -20
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"regexp"
"strings"
@@ -25,7 +25,7 @@ import (
)
// username value regex pattern
var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)
var usernameRegex = regexp.MustCompile(`^[\w][\w\.\-]*$`)
// RecordUpsert is a [models.Record] upsert (create/update) form.
type RecordUpsert struct {
@@ -117,7 +117,7 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
return t
}
func (form *RecordUpsert) extractRequestInfo(
func (form *RecordUpsert) extractRequestData(
r *http.Request,
keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) {
@@ -200,8 +200,12 @@ func (form *RecordUpsert) extractMultipartFormData(
files, err := rest.FindUploadedFiles(r, fullKey)
if err != nil || len(files) == 0 {
if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
log.Printf("%q uploaded file error: %v\n", fullKey, err)
if err != nil && err != http.ErrMissingFile {
form.app.Logger().Debug(
"Uploaded file error",
slog.String("key", fullKey),
slog.String("error", err.Error()),
)
}
// skip invalid or missing file(s)
@@ -219,12 +223,12 @@ func (form *RecordUpsert) extractMultipartFormData(
//
// File upload is supported only via multipart/form-data.
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
requestInfo, uploadedFiles, err := form.extractRequestInfo(r, keyPrefix)
requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
if err != nil {
return err
}
if err := form.LoadData(requestInfo); err != nil {
if err := form.LoadData(requestData); err != nil {
return err
}
@@ -349,39 +353,39 @@ func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
}
// LoadData loads and normalizes the provided regular record data fields into the form.
func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
// load base system fields
if v, ok := requestInfo[schema.FieldNameId]; ok {
if v, ok := requestData[schema.FieldNameId]; ok {
form.Id = cast.ToString(v)
}
// load auth system fields
if form.record.Collection().IsAuth() {
if v, ok := requestInfo[schema.FieldNameUsername]; ok {
if v, ok := requestData[schema.FieldNameUsername]; ok {
form.Username = cast.ToString(v)
}
if v, ok := requestInfo[schema.FieldNameEmail]; ok {
if v, ok := requestData[schema.FieldNameEmail]; ok {
form.Email = cast.ToString(v)
}
if v, ok := requestInfo[schema.FieldNameEmailVisibility]; ok {
if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
form.EmailVisibility = cast.ToBool(v)
}
if v, ok := requestInfo[schema.FieldNameVerified]; ok {
if v, ok := requestData[schema.FieldNameVerified]; ok {
form.Verified = cast.ToBool(v)
}
if v, ok := requestInfo["password"]; ok {
if v, ok := requestData["password"]; ok {
form.Password = cast.ToString(v)
}
if v, ok := requestInfo["passwordConfirm"]; ok {
if v, ok := requestData["passwordConfirm"]; ok {
form.PasswordConfirm = cast.ToString(v)
}
if v, ok := requestInfo["oldPassword"]; ok {
if v, ok := requestData["oldPassword"]; ok {
form.OldPassword = cast.ToString(v)
}
}
// replace modifiers (if any)
requestInfo = form.record.ReplaceModifers(requestInfo)
requestData = form.record.ReplaceModifers(requestData)
// create a shallow copy of form.data
var extendedData = make(map[string]any, len(form.data))
@@ -390,7 +394,7 @@ func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
}
// extend form.data with the request data
rawData, err := json.Marshal(requestInfo)
rawData, err := json.Marshal(requestData)
if err != nil {
return err
}
@@ -794,8 +798,11 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
//
// for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)...
if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
log.Println(err)
if err := form.processFilesToDelete(); err != nil {
form.app.Logger().Debug(
"Failed to delete old files",
slog.String("error", err.Error()),
)
}
return nil
+1 -1
View File
@@ -753,7 +753,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
"create with all allowed auth fields",
"",
map[string]any{
"username": "test_new",
"username": "test_new-a.b",
"email": "test_new@example.com",
"emailVisibility": true,
"password": "12345678",
+3 -3
View File
@@ -82,9 +82,6 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*m
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
return errors.New("A verification email was already sent.")
}
// update last sent timestamp
record.SetLastVerificationSentAt(types.NowDateTime())
}
return runInterceptors(record, func(m *models.Record) error {
@@ -96,6 +93,9 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*m
return err
}
// update last sent timestamp
m.SetLastVerificationSentAt(types.NowDateTime())
return form.dao.SaveRecord(m)
}, interceptors...)
}
+2 -2
View File
@@ -182,7 +182,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastVerificationSentAt.String() == authRecord.LastVerificationSentAt().String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
if interceptorLastVerificationSentAt.String() != authRecord.LastVerificationSentAt().String() {
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
}
}
+16 -18
View File
@@ -4,9 +4,12 @@ import (
"os"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/types"
)
// SettingsUpsert is a [settings.Settings] upsert (create/update) form.
@@ -58,32 +61,27 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
return runInterceptors(form.Settings, func(s *settings.Settings) error {
form.Settings = s
oldSettings, err := form.app.Settings().Clone()
if err != nil {
return err
}
// eagerly merge the application settings with the form ones
if err := form.app.Settings().Merge(form.Settings); err != nil {
return err
}
// persists settings change
encryptionKey := os.Getenv(form.app.EncryptionEnv())
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
// try to revert app settings
form.app.Settings().Merge(oldSettings)
return err
}
// explicitly trigger old logs deletion
form.app.LogsDao().DeleteOldRequests(
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
)
// reload app settings
if err := form.app.RefreshSettings(); err != nil {
return err
}
// try to clear old logs not matching the new settings
createdBefore := time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays).UTC().Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] <= {:date} OR [[level]] < {:level}", dbx.Params{
"date": createdBefore,
"level": form.Settings.Logs.MinLevel,
})
form.app.LogsDao().NonconcurrentDB().Delete((&models.Log{}).TableName(), expr).Execute()
// no logs are allowed -> try to reclaim preserved disk space after the previous delete operation
if form.Settings.Logs.MaxDays == 0 {
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
form.app.LogsDao().Vacuum()
}
+17 -4
View File
@@ -132,11 +132,14 @@ func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField,
options, _ := field.Options.(*schema.TextOptions)
if options.Min != nil && len(val) < *options.Min {
// note: casted to []rune to count multi-byte chars as one
length := len([]rune(val))
if options.Min != nil && length < *options.Min {
return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min))
}
if options.Max != nil && len(val) > *options.Max {
if options.Max != nil && length > *options.Max {
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max))
}
@@ -158,6 +161,10 @@ func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField
options, _ := field.Options.(*schema.NumberOptions)
if options.NoDecimal && val != float64(int64(val)) {
return validation.NewError("validation_no_decimal_constraint", "Decimal numbers are not allowed")
}
if options.Min != nil && val < *options.Min {
return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *options.Min))
}
@@ -294,8 +301,14 @@ func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField,
}
raw, _ := types.ParseJsonRaw(value)
rawStr := strings.TrimSpace(raw.String())
options, _ := field.Options.(*schema.JsonOptions)
if len(raw) > options.MaxSize {
return validation.NewError("validation_json_size_limit", fmt.Sprintf("The maximum allowed JSON size is %v bytes", options.MaxSize))
}
rawStr := strings.TrimSpace(raw.String())
if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
return requiredErr
}
@@ -372,7 +385,7 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
Row(&total)
if total != len(ids) {
return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids")
return validation.NewError("validation_missing_rel_records", "Failed to find all relation records with the provided ids")
}
// ---
+88 -28
View File
@@ -63,15 +63,17 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
Name: "field2",
Required: true,
Type: schema.FieldTypeText,
Options: &schema.TextOptions{
Pattern: pattern,
},
},
&schema.SchemaField{
Name: "field3",
Unique: true,
Type: schema.FieldTypeText,
Options: &schema.TextOptions{
Min: &min,
Max: &max,
Pattern: pattern,
Min: &min,
Max: &max,
},
},
)
@@ -109,6 +111,16 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
nil,
[]string{"field3"},
},
{
"(text) check min constraint with multi-bytes char",
map[string]any{
"field1": "test",
"field2": "test",
"field3": "𝌆", // 4 bytes should be counted as 1 char
},
nil,
[]string{"field3"},
},
{
"(text) check max constraint",
map[string]any{
@@ -119,15 +131,25 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
nil,
[]string{"field3"},
},
{
"(text) check max constraint with multi-bytes chars",
map[string]any{
"field1": "test",
"field2": "test",
"field3": strings.Repeat("𝌆", max), // shouldn't exceed the max limit even though max*4bytes chars are used
},
nil,
[]string{},
},
{
"(text) check pattern constraint",
map[string]any{
"field1": nil,
"field2": "test",
"field3": "test!",
"field2": "test!",
"field3": "test",
},
nil,
[]string{"field3"},
[]string{"field2"},
},
{
"(text) valid data (only required)",
@@ -180,6 +202,13 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
Max: &max,
},
},
&schema.SchemaField{
Name: "field4",
Type: schema.FieldTypeNumber,
Options: &schema.NumberOptions{
NoDecimal: true,
},
},
)
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
@@ -201,6 +230,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
"field1": nil,
"field2": nil,
"field3": nil,
"field4": nil,
},
nil,
[]string{"field2"},
@@ -211,6 +241,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
"field1": "invalid",
"field2": "invalid",
"field3": "invalid",
"field4": "invalid",
},
nil,
[]string{"field2"},
@@ -244,6 +275,15 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
nil,
[]string{"field3"},
},
{
"(number) check NoDecimal",
map[string]any{
"field2": 1,
"field4": 456.789,
},
nil,
[]string{"field4"},
},
{
"(number) valid data (only required)",
map[string]any{
@@ -258,6 +298,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
"field1": nil,
"field2": 123, // test value cast
"field3": max,
"field4": 456,
},
nil,
[]string{},
@@ -821,16 +862,25 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
&schema.SchemaField{
Name: "field1",
Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 10,
},
},
&schema.SchemaField{
Name: "field2",
Required: true,
Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 9999,
},
},
&schema.SchemaField{
Name: "field3",
Unique: true,
Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 9999,
},
},
)
if err := app.Dao().SaveCollection(collection); err != nil {
@@ -897,6 +947,15 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(json) check MaxSize constraint",
map[string]any{
"field1": `"123456789"`, // max 10bytes
"field2": 123,
},
nil,
[]string{"field1"},
},
{
"(json) check json text invalid obj, array and number normalizations",
map[string]any{
@@ -928,9 +987,9 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
{
"(json) valid data - all fields with normalizations",
map[string]any{
"field1": []string{"a", "b", "c"},
"field1": `"12345678"`,
"field2": 123,
"field3": `"test"`,
"field3": []string{"a", "b", "c"},
},
nil,
[]string{},
@@ -1232,29 +1291,30 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) {
for i, s := range scenarios {
validator := validators.NewRecordDataValidator(dao, record, s.files)
result := validator.Validate(s.data)
prefix := fmt.Sprintf("%d", i)
if s.name != "" {
prefix = s.name
prefix := s.name
if prefix == "" {
prefix = fmt.Sprintf("%d", i)
}
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", prefix, result)
continue
}
t.Run(prefix, func(t *testing.T) {
validator := validators.NewRecordDataValidator(dao, record, s.files)
result := validator.Validate(s.data)
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs)
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
}
}
// 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)
}
}
})
}
}
+59 -58
View File
@@ -1,95 +1,96 @@
module github.com/pocketbase/pocketbase
go 1.18
go 1.21
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go v1.44.313
github.com/aws/aws-sdk-go v1.48.16
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.1
github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1
github.com/dop251/goja_nodejs v0.0.0-20230731184339-9ba67d720a81
github.com/fatih/color v1.15.0
github.com/fsnotify/fsnotify v1.6.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/ganigeorgiev/fexpr v0.3.0
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c
github.com/fatih/color v1.16.0
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/ganigeorgiev/fexpr v0.4.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/goccy/go-json v0.10.2
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/mattn/go-sqlite3 v1.14.17
github.com/pocketbase/dbx v1.10.0
github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1
github.com/spf13/cast v1.5.1
github.com/spf13/cobra v1.7.0
gocloud.dev v0.32.0
golang.org/x/crypto v0.11.0
golang.org/x/net v0.12.0
golang.org/x/oauth2 v0.10.0
golang.org/x/sync v0.3.0
modernc.org/sqlite v1.24.0
github.com/mattn/go-sqlite3 v1.14.18
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4
github.com/spf13/cast v1.6.0
github.com/spf13/cobra v1.8.0
gocloud.dev v0.35.0
golang.org/x/crypto v0.16.0
golang.org/x/net v0.19.0
golang.org/x/oauth2 v0.15.0
golang.org/x/sync v0.5.0
modernc.org/sqlite v1.27.0
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.20.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.31 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.75 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.0 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.13.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.21.0 // indirect
github.com/aws/smithy-go v1.14.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.26.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.9.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/term v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.11.1 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.134.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf // indirect
google.golang.org/grpc v1.57.0 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.16.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/api v0.153.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.14 // indirect
modernc.org/libc v1.24.1 // 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.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+150 -143
View File
@@ -1,9 +1,14 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.6 h1:8uYAkj3YHTP/1iwReuHPxLSbdcyc+dSBbzFMrVwDR6Q=
cloud.google.com/go/compute v1.22.0 h1:cB8R6FtUtT1TYGl5R3xuxnW6OUIc/DrT2aiR16TTG7Y=
cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w=
cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -12,53 +17,53 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.44.313 h1:u6EuNQqgAmi09GEZ5g/XGHLF0XV31WcdU5rnHyIBHBc=
github.com/aws/aws-sdk-go v1.44.313/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/aws/aws-sdk-go-v2 v1.20.0 h1:INUDpYLt4oiPOJl0XwZDK2OVAVf0Rzo+MGVTv9f+gy8=
github.com/aws/aws-sdk-go-v2 v1.20.0/go.mod h1:uWOr0m0jDsiWw8nnXiqZ+YG6LdvAlGYDLLf2NmHZoy4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.11 h1:/MS8AzqYNAhhRNalOmxUvYs8VEbNGifTnzhPFdcRQkQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.11/go.mod h1:va22++AdXht4ccO3kH2SHkHHYvZ2G9Utz+CXKmm2CaU=
github.com/aws/aws-sdk-go-v2/config v1.18.31 h1:CcacHsJjsPtHpe1MaopwPddUErmLnl+X77+7n4G2KkY=
github.com/aws/aws-sdk-go-v2/config v1.18.31/go.mod h1:pnSeuahFFvtScCHy0INXLxJ4N8H7KncD5u6A48bx3/8=
github.com/aws/aws-sdk-go-v2/credentials v1.13.30 h1:4pt4sI4OwXrrWUGuGr5NEb2g+4IBUB/I2BVj0t2Ak7Q=
github.com/aws/aws-sdk-go-v2/credentials v1.13.30/go.mod h1:Scpo/dGUdxAtRKsNCaXMXONnl3gvvugbXVldy5Fz2DQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7 h1:X3H6+SU21x+76LRglk21dFRgMTJMa5QcpW+SqUf5BBg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7/go.mod h1:3we0V09SwcJBzNlnyovrR2wWJhWmVdqAsmVs4uronv8=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.75 h1:dKQyBQCZiu47cQ47UhwsVEr5TYu4YqoyAnue6qgybh0=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.75/go.mod h1:ZduPOCZCNBJ4anGPXD9oU+3tJgOmm9VdoBQuNHvYnFY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37 h1:zr/gxAZkMcvP71ZhQOcvdm8ReLjFgIXnIn0fw5AM7mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37/go.mod h1:Pdn4j43v49Kk6+82spO3Tu5gSeQXRsxo56ePPQAvFiA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31 h1:0HCMIkAkVY9KMgueD8tf4bRTUanzEYvhw7KkPXIMpO0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31/go.mod h1:fTJDMe8LOFYtqiFFFeHA+SVMAwqLhoq0kcInYoLa9Js=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38 h1:+i1DOFrW3YZ3apE45tCal9+aDKK6kNEbW6Ib7e1nFxE=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38/go.mod h1:1/jLp0OgOaWIetycOmycW+vYTYgTZFPttJQRgsI1PoU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.0 h1:U5yySdwt2HPo/pnQec04DImLzWORbeWML1fJiLkKruI=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.0/go.mod h1:EhC/83j8/hL/UB1WmExo3gkElaja/KlmZM/gl1rTfjM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.12 h1:uAiiHnWihGP2rVp64fHwzLDrswGjEjsPszwRYMiYQPU=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.12/go.mod h1:fUTHpOXqRQpXvEpDPSa3zxCc2fnpW6YnBoba+eQr+Bg=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.32 h1:kvN1jPHr9UffqqG3bSgZ8tx4+1zKVHz/Ktw/BwW6hX8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.32/go.mod h1:QmMEM7es84EUkbYWcpnkx8i5EW2uERPfrTFeOch128Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31 h1:auGDJ0aLZahF5SPvkJ6WcUuX7iQ7kyl2MamV7Tm8QBk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31/go.mod h1:3+lloe3sZuBQw1aBc5MyndvodzQlyqCZ7x1QPDHaWP4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.0 h1:Wgjft9X4W5pMeuqgPCHIQtbZ87wsgom7S5F8obreg+c=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.0/go.mod h1:FWNzS4+zcWAP05IF7TDYTY1ysZAzIvogxWaDT9p8fsA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.0 h1:Flb+Uw+ewvmbZiaXEl+aIs1HygnwztxZmhkCe1b+YQg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.0/go.mod h1:6SOWLiobcZZshbmECRTADIRYliPL0etqFSigauQEeT0=
github.com/aws/aws-sdk-go-v2/service/sso v1.13.0 h1:agnjK56/1jtGPehxV8QZ/AYHV++pEfl7CpYbWjHjBDc=
github.com/aws/aws-sdk-go-v2/service/sso v1.13.0/go.mod h1:TC9BubuFMVScIU+TLKamO6VZiYTkYoEHqlSQwAe2omw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.0 h1:g0Rr6COTBEaIG9TFQ0GmRkPWOGuDfySGSq2PlMcclrY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.0/go.mod h1:XO/VcyoQ8nKyKfFW/3DMsRQXsfh/052tHTWmg3xBXRg=
github.com/aws/aws-sdk-go-v2/service/sts v1.21.0 h1:HI1YIL5Q9FtucxF5tcNpzCEyLnkeUcqg6xtOx8u09S4=
github.com/aws/aws-sdk-go-v2/service/sts v1.21.0/go.mod h1:G8SbvL0rFk4WOJroU8tKBczhsbhj2p/YY7qeJezJ3CI=
github.com/aws/smithy-go v1.14.0 h1:+X90sB94fizKjDmwb4vyl2cTTPXTE5E2G/1mjByb0io=
github.com/aws/smithy-go v1.14.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/aws-sdk-go v1.48.16 h1:mcj2/9J/MJ55Dov+ocMevhR8Jv6jW/fAxbrn4a1JFc8=
github.com/aws/aws-sdk-go v1.48.16/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk=
github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o=
github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 h1:FnLf60PtjXp8ZOzQfhJVsqF0OtYKQZWQfqOLshh8YXg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7/go.mod h1:tDVvl8hyU6E9B8TrnNrZQEVkQlB8hjJwcgpPhgtlnNg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
@@ -71,37 +76,40 @@ github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwu
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/domodwyer/mailyak/v3 v3.6.1 h1:o3vjinPjPUpw/2Ng5N91JrlFDkoIKu4HTHw3U/CNemE=
github.com/domodwyer/mailyak/v3 v3.6.1/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20230531210528-d7324b2d74f7/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1 h1:sC/DYk3eEi5cKkpJX1vl+CpAM138dmuW7rutje9Eo4E=
github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/dop251/goja_nodejs v0.0.0-20230731184339-9ba67d720a81 h1:1e6KLyFnVS5BAENTnqll6IezpFqTDAmIsL0hkjBuMcY=
github.com/dop251/goja_nodejs v0.0.0-20230731184339-9ba67d720a81/go.mod h1:X2TOTJ+Uamd454RFp7ig2tmP3hQg0Z2Qk8gbVQmU0mk=
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/ganigeorgiev/fexpr v0.3.0 h1:RwSyJBME+g/XdzlUW0paH/4VXhLHPg+rErtLeC7K8Ew=
github.com/ganigeorgiev/fexpr v0.3.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/ganigeorgiev/fexpr v0.4.0 h1:ojitI+VMNZX/odeNL1x3RzTTE8qAIVvnSSYPNAnQFDI=
github.com/ganigeorgiev/fexpr v0.4.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -120,6 +128,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -129,22 +138,27 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-replayers/grpcreplay v1.1.0 h1:S5+I3zYyZ+GQz68OfbURDdt/+cSMqCK1wrvNx7WBzTE=
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 h1:n6vlPhxsA+BW/XsS5+uqi7GyzaLa5MH7qlSLBZtRdiA=
github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8/go.mod h1:Jh3hGz2jkYak8qXPD19ryItVnUgpgeqzdkY/D0EaeuA=
github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc=
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.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
github.com/googleapis/enterprise-certificate-proxy v0.2.5 h1:UR4rDjcgpgEnqpIEvkiqTYKBCKLNmlge2eVjoZfySzM=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -162,6 +176,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -173,30 +188,30 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.0 h1:58VIT7r6T+BnVbYVosvGBsPjQEic3/VFRYGT823vWSQ=
github.com/pocketbase/dbx v1.10.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1 h1:0FQXnhUhBeF/z6UDj9Zm8GQHFHJSUtqLr1h4/YXf3vc=
github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1/go.mod h1:dOJ+pCyqm/jRn5kO/TX598J0e5xGDcJAZerK5atCrKI=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4 h1:85kAYIKrKEeau7WgXg8B7Km8etrVavJAyH7XcR5MkFw=
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4/go.mod h1:dOJ+pCyqm/jRn5kO/TX598J0e5xGDcJAZerK5atCrKI=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -208,6 +223,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -215,24 +231,23 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
gocloud.dev v0.32.0 h1:jHf8WSkByuAuXcvFt04OiiQH+N0zaRtxI6iEph8Bq8Y=
gocloud.dev v0.32.0/go.mod h1:m/x/N9cRjDF5MD0i5TLFbKbqkGffl/qayXA9FcMT5Oc=
gocloud.dev v0.35.0 h1:x/Gtt5OJdT4j+ir1AXAIXb7bBnFawXAAaJptCUGk3HU=
gocloud.dev v0.35.0/go.mod h1:wbyF+BhfdtLWyUtVEWRW13hFLb1vXnV2ovEhYGQe3ck=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
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.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g=
golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -243,21 +258,17 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -269,20 +280,13 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/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.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -290,12 +294,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -304,34 +306,35 @@ golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.11.1 h1:ojD5zOW8+7dOGzdnNgersm8aPfcDjhMp12UfG93NIMc=
golang.org/x/tools v0.11.1/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.134.0 h1:ktL4Goua+UBgoP1eL1/60LwZJqa1sIzkLmvoR3hR6Gw=
google.golang.org/api v0.134.0/go.mod h1:sjRL3UnjTx5UqNQS9EWr9N8p7xbHpy1k0XGRLCf3Spk=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.153.0 h1:N1AwGhielyKFaUqH07/ZSIQR3uNPcV7NVw0vj+j4iR4=
google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg=
google.golang.org/genproto/googleapis/api v0.0.0-20230717213848-3f92550aa753 h1:lCbbUxUDD+DiXx9Q6F/ttL0aAu7N2pz8XnmMm8ZW4NE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf h1:guOdSPaeFgN+jEJwTo1dQ71hdBm+yKSCCKuTRkJzcVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:zBEcrKX2ZOcEkHWxBPAIvYUWOKKMIhYcmNiUIu2ji3I=
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f h1:Vn+VyHU5guc9KjB5KrjI2q0wCOWEOIh0OEsleqakHJg=
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -362,23 +365,27 @@ lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.14 h1:af6KNtFgsVmnDYrWk3PQCS9XT6BXe7o3ZFJKkIKvXNQ=
modernc.org/ccgo/v3 v3.16.14/go.mod h1:mPDSujUIaTNWQSG4eqKw+atqLOEbma6Ncsa94WbC9zo=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.37.0 h1:WerjebcsP6A7Jy+f2lCnHAkiSTLf7IaSftBYUtoswak=
modernc.org/libc v1.37.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
+1 -1
View File
@@ -1,5 +1,5 @@
run:
go: 1.18
go: 1.21
concurrency: 4
timeout: 10m
+2 -2
View File
@@ -85,7 +85,7 @@ func init() {
);
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
CREATE UNIQUE INDEX _externalAuths_collection_provider_idx on {{_externalAuths}} ([[collectionId]], [[provider]], [[providerId]]);
`).Execute()
if tablesErr != nil {
return tablesErr
@@ -100,7 +100,7 @@ func init() {
return err
}
// inserts the system profiles collection
// inserts the system users collection
// -----------------------------------------------------------
usersCollection := &models.Collection{}
usersCollection.MarkAsNew()
+28
View File
@@ -0,0 +1,28 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// Resave all view collections to ensure that the proper id normalization is applied.
// (see https://github.com/pocketbase/pocketbase/issues/3110)
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
if err != nil {
return nil
}
for _, collection := range collections {
// ignore errors to allow users to adjust
// the view queries after app start
dao.SaveCollection(collection)
}
return nil
}, nil)
}
@@ -0,0 +1,62 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// Copy the now deprecated RelationOptions.DisplayFields values from
// all relation fields and register its value as Presentable under
// the specific field in the related collection.
//
// If there is more than one relation to a single collection with explicitly
// set DisplayFields only one of the configuration will be copied.
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
indexedCollections := make(map[string]*models.Collection, len(collections))
for _, collection := range collections {
indexedCollections[collection.Id] = collection
}
for _, collection := range indexedCollections {
for _, f := range collection.Schema.Fields() {
if f.Type != schema.FieldTypeRelation {
continue
}
options, ok := f.Options.(*schema.RelationOptions)
if !ok || len(options.DisplayFields) == 0 {
continue
}
relCollection, ok := indexedCollections[options.CollectionId]
if !ok {
continue
}
for _, name := range options.DisplayFields {
relField := relCollection.Schema.GetFieldByName(name)
if relField != nil {
relField.Presentable = true
}
}
// only raw model save
if err := dao.Save(relCollection); err != nil {
return err
}
}
}
return nil
}, nil)
}
@@ -0,0 +1,23 @@
package migrations
import (
"github.com/pocketbase/dbx"
)
// Fixes the unique _externalAuths constraint for old installations
// to allow a single OAuth2 provider to be registered for different auth collections.
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
_, createErr := db.NewQuery("CREATE UNIQUE INDEX IF NOT EXISTS _externalAuths_collection_provider_idx on {{_externalAuths}} ([[collectionId]], [[provider]], [[providerId]])").Execute()
if createErr != nil {
return createErr
}
_, dropErr := db.NewQuery("DROP INDEX IF EXISTS _externalAuths_provider_providerId_idx").Execute()
if dropErr != nil {
return dropErr
}
return nil
}, nil)
}
@@ -0,0 +1,51 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// Update all collections with json fields to have a default MaxSize json field option.
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
// note: update even the view collections to prevent
// unnecessary change detections during the automigrate
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, collection := range collections {
var needSave bool
for _, f := range collection.Schema.Fields() {
if f.Type != schema.FieldTypeJson {
continue
}
options, _ := f.Options.(*schema.JsonOptions)
if options != nil {
options = &schema.JsonOptions{}
}
options.MaxSize = 2000000 // 2mb
f.Options = options
needSave = true
}
if !needSave {
continue
}
// save only the collection model without updating its records table
if err := dao.Save(collection); err != nil {
return err
}
}
return nil
}, nil)
}
+2 -2
View File
@@ -8,8 +8,8 @@ import (
var LogsMigrations migrate.MigrationsList
func init() {
LogsMigrations.Register(func(db dbx.Builder) (err error) {
_, err = db.NewQuery(`
LogsMigrations.Register(func(db dbx.Builder) error {
_, err := db.NewQuery(`
CREATE TABLE {{_requests}} (
[[id]] TEXT PRIMARY KEY NOT NULL,
[[url]] TEXT DEFAULT "" NOT NULL,
@@ -0,0 +1,57 @@
package logs
import (
"github.com/pocketbase/dbx"
)
func init() {
LogsMigrations.Register(func(db dbx.Builder) error {
if _, err := db.DropTable("_requests").Execute(); err != nil {
return err
}
_, err := db.NewQuery(`
CREATE TABLE {{_logs}} (
[[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
[[level]] INTEGER DEFAULT 0 NOT NULL,
[[message]] TEXT DEFAULT "" NOT NULL,
[[data]] JSON DEFAULT "{}" NOT NULL,
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
CREATE INDEX _logs_level_idx on {{_logs}} ([[level]]);
CREATE INDEX _logs_message_idx on {{_logs}} ([[message]]);
CREATE INDEX _logs_created_hour_idx on {{_logs}} (strftime('%Y-%m-%d %H:00:00', [[created]]));
`).Execute()
return err
}, func(db dbx.Builder) error {
if _, err := db.DropTable("_logs").Execute(); err != nil {
return err
}
_, err := db.NewQuery(`
CREATE TABLE {{_requests}} (
[[id]] TEXT PRIMARY KEY NOT NULL,
[[url]] TEXT DEFAULT "" NOT NULL,
[[method]] TEXT DEFAULT "get" NOT NULL,
[[status]] INTEGER DEFAULT 200 NOT NULL,
[[auth]] TEXT DEFAULT "guest" NOT NULL,
[[ip]] TEXT DEFAULT "127.0.0.1" NOT NULL,
[[referer]] TEXT DEFAULT "" NOT NULL,
[[userAgent]] TEXT DEFAULT "" NOT NULL,
[[meta]] JSON DEFAULT "{}" NOT NULL,
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
CREATE INDEX _request_status_idx on {{_requests}} ([[status]]);
CREATE INDEX _request_auth_idx on {{_requests}} ([[auth]]);
CREATE INDEX _request_ip_idx on {{_requests}} ([[ip]]);
CREATE INDEX _request_created_hour_idx on {{_requests}} (strftime('%Y-%m-%d %H:00:00', [[created]]));
`).Execute()
return err
})
}
+1
View File
@@ -179,6 +179,7 @@ type CollectionAuthOptions struct {
AllowEmailAuth bool `form:"allowEmailAuth" json:"allowEmailAuth"`
RequireEmail bool `form:"requireEmail" json:"requireEmail"`
ExceptEmailDomains []string `form:"exceptEmailDomains" json:"exceptEmailDomains"`
OnlyVerified bool `form:"onlyVerified" json:"onlyVerified"`
OnlyEmailDomains []string `form:"onlyEmailDomains" json:"onlyEmailDomains"`
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
}
+103 -93
View File
@@ -89,20 +89,22 @@ func TestCollectionMarshalJSON(t *testing.T) {
},
{
"auth type + non empty options",
models.Collection{BaseModel: models.BaseModel{Id: "test"}, Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "allowOAuth2Auth": true, "minPasswordLength": 4}},
`{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
models.Collection{BaseModel: models.BaseModel{Id: "test"}, Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "allowOAuth2Auth": true, "minPasswordLength": 4, "onlyVerified": true}},
`{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"onlyVerified":true,"requireEmail":false}}`,
},
}
for _, s := range scenarios {
result, err := s.collection.MarshalJSON()
if err != nil {
t.Errorf("[%s] Unexpected error %v", s.name, err)
continue
}
if string(result) != s.expected {
t.Errorf("[%s] Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
}
t.Run(s.name, func(t *testing.T) {
result, err := s.collection.MarshalJSON()
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
if string(result) != s.expected {
t.Fatalf("Expected\n%v\ngot\n%v", s.expected, string(result))
}
})
}
}
@@ -135,22 +137,24 @@ func TestCollectionBaseOptions(t *testing.T) {
}
for _, s := range scenarios {
result := s.collection.BaseOptions()
t.Run(s.name, func(t *testing.T) {
result := s.collection.BaseOptions()
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Fatalf("Expected \n%v \ngot \n%v", s.expected, strEncoded)
}
})
}
}
func TestCollectionAuthOptions(t *testing.T) {
options := types.JsonMap{"test": 123, "minPasswordLength": 4}
expectedSerialization := `{"manageRule":null,"allowOAuth2Auth":false,"allowUsernameAuth":false,"allowEmailAuth":false,"requireEmail":false,"exceptEmailDomains":null,"onlyEmailDomains":null,"minPasswordLength":4}`
expectedSerialization := `{"manageRule":null,"allowOAuth2Auth":false,"allowUsernameAuth":false,"allowEmailAuth":false,"requireEmail":false,"exceptEmailDomains":null,"onlyVerified":false,"onlyEmailDomains":null,"minPasswordLength":4}`
scenarios := []struct {
name string
@@ -180,16 +184,18 @@ func TestCollectionAuthOptions(t *testing.T) {
}
for _, s := range scenarios {
result := s.collection.AuthOptions()
t.Run(s.name, func(t *testing.T) {
result := s.collection.AuthOptions()
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Fatalf("Expected \n%v \ngot \n%v", s.expected, strEncoded)
}
})
}
}
@@ -225,16 +231,18 @@ func TestCollectionViewOptions(t *testing.T) {
}
for _, s := range scenarios {
result := s.collection.ViewOptions()
t.Run(s.name, func(t *testing.T) {
result := s.collection.ViewOptions()
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Fatalf("Expected \n%v \ngot \n%v", s.expected, strEncoded)
}
})
}
}
@@ -257,24 +265,25 @@ func TestNormalizeOptions(t *testing.T) {
{
"auth type",
models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
`{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
`{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"onlyVerified":false,"requireEmail":false}`,
},
}
for _, s := range scenarios {
if err := s.collection.NormalizeOptions(); err != nil {
t.Errorf("[%s] Unexpected error %v", s.name, err)
continue
}
t.Run(s.name, func(t *testing.T) {
if err := s.collection.NormalizeOptions(); err != nil {
t.Fatalf("Unexpected error %v", err)
}
encoded, err := json.Marshal(s.collection.Options)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(s.collection.Options)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Fatalf("Expected \n%v \ngot \n%v", s.expected, strEncoded)
}
})
}
}
@@ -325,24 +334,25 @@ func TestSetOptions(t *testing.T) {
"auth type",
models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123}},
map[string]any{"test": 456, "minPasswordLength": 4},
`{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
`{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"onlyVerified":false,"requireEmail":false}`,
},
}
for _, s := range scenarios {
if err := s.collection.SetOptions(s.options); err != nil {
t.Errorf("[%s] Unexpected error %v", s.name, err)
continue
}
t.Run(s.name, func(t *testing.T) {
if err := s.collection.SetOptions(s.options); err != nil {
t.Fatalf("Unexpected error %v", err)
}
encoded, err := json.Marshal(s.collection.Options)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(s.collection.Options)
if err != nil {
t.Fatal(err)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
}
if strEncoded := string(encoded); strEncoded != s.expected {
t.Fatalf("Expected\n%v\ngot\n%v", s.expected, strEncoded)
}
})
}
}
@@ -418,25 +428,25 @@ func TestCollectionAuthOptionsValidate(t *testing.T) {
}
for _, s := range scenarios {
result := s.options.Validate()
t.Run(s.name, func(t *testing.T) {
result := s.options.Validate()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
if len(errs) != len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
continue
}
for key := range errs {
if !list.ExistInSlice(key, s.expectedErrors) {
t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
}
}
if len(errs) != len(s.expectedErrors) {
t.Fatalf("Expected error keys %v, got errors \n%v", s.expectedErrors, result)
}
for key := range errs {
if !list.ExistInSlice(key, s.expectedErrors) {
t.Fatalf("Unexpected error key %q in \n%v", key, errs)
}
}
})
}
}
@@ -461,24 +471,24 @@ func TestCollectionViewOptionsValidate(t *testing.T) {
}
for _, s := range scenarios {
result := s.options.Validate()
t.Run(s.name, func(t *testing.T) {
result := s.options.Validate()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
if len(errs) != len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
continue
}
for key := range errs {
if !list.ExistInSlice(key, s.expectedErrors) {
t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
}
}
if len(errs) != len(s.expectedErrors) {
t.Fatalf("Expected error keys %v, got errors \n%v", s.expectedErrors, result)
}
for key := range errs {
if !list.ExistInSlice(key, s.expectedErrors) {
t.Fatalf("Unexpected error key %q in \n%v", key, errs)
}
}
})
}
}
+19
View File
@@ -0,0 +1,19 @@
package models
import (
"github.com/pocketbase/pocketbase/tools/types"
)
var _ Model = (*Log)(nil)
type Log struct {
BaseModel
Data types.JsonMap `db:"data" json:"data"`
Message string `db:"message" json:"message"`
Level int `db:"level" json:"level"`
}
func (m *Log) TableName() string {
return "_logs"
}
+1 -1
View File
@@ -8,7 +8,7 @@ var _ Model = (*Request)(nil)
const (
RequestAuthGuest = "guest"
RequestAuthAdmin = "admin"
RequestAuthRecord = "auth_record"
RequestAuthRecord = "authRecord"
)
type Request struct {
+1 -1
View File
@@ -9,12 +9,12 @@ import (
// RequestInfo defines a HTTP request data struct, usually used
// as part of the `@request.*` filter resolver.
type RequestInfo struct {
Method string `json:"method"`
Query map[string]any `json:"query"`
Data map[string]any `json:"data"`
Headers map[string]any `json:"headers"`
AuthRecord *Record `json:"authRecord"`
Admin *Admin `json:"admin"`
Method string `json:"method"`
}
// HasModifierDataKeys loosely checks if the current struct has any modifier Data keys.
+37 -7
View File
@@ -132,6 +132,10 @@ type SchemaField struct {
Type string `form:"type" json:"type"`
Required bool `form:"required" json:"required"`
// Presentable indicates whether the field is suitable for
// visualization purposes (eg. in the Admin UI relation views).
Presentable bool `form:"presentable" json:"presentable"`
// Deprecated: This field is no-op and will be removed in future versions.
// Please use the collection.Indexes field to define a unique constraint.
Unique bool `form:"unique" json:"unique"`
@@ -306,6 +310,7 @@ func (f *SchemaField) PrepareValue(value any) any {
} else if str == "null" || str == "true" || str == "false" {
val = str
} else if ((str[0] >= '0' && str[0] <= '9') ||
str[0] == '-' ||
str[0] == '"' ||
str[0] == '[' ||
str[0] == '{') &&
@@ -452,21 +457,36 @@ func (o *TextOptions) checkRegex(value any) error {
// -------------------------------------------------------------------
type NumberOptions struct {
Min *float64 `form:"min" json:"min"`
Max *float64 `form:"max" json:"max"`
Min *float64 `form:"min" json:"min"`
Max *float64 `form:"max" json:"max"`
NoDecimal bool `form:"noDecimal" json:"noDecimal"`
}
func (o NumberOptions) Validate() error {
var maxRules []validation.Rule
if o.Min != nil && o.Max != nil {
maxRules = append(maxRules, validation.Min(*o.Min))
maxRules = append(maxRules, validation.Min(*o.Min), validation.By(o.checkNoDecimal))
}
return validation.ValidateStruct(&o,
validation.Field(&o.Min, validation.By(o.checkNoDecimal)),
validation.Field(&o.Max, maxRules...),
)
}
func (o *NumberOptions) checkNoDecimal(value any) error {
v, _ := value.(*float64)
if v == nil || !o.NoDecimal {
return nil // nothing to check
}
if *v != float64(int64(*v)) {
return validation.NewError("validation_no_decimal_constraint", "Decimal numbers are not allowed.")
}
return nil
}
// -------------------------------------------------------------------
type BoolOptions struct {
@@ -519,6 +539,12 @@ func (o UrlOptions) Validate() error {
// -------------------------------------------------------------------
type EditorOptions struct {
// ConvertUrls is usually used to instruct the editor whether to
// apply url conversion (eg. stripping the domain name in case the
// urls are using the same domain as the one where the editor is loaded).
//
// (see also https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls)
ConvertUrls bool `form:"convertUrls" json:"convertUrls"`
}
func (o EditorOptions) Validate() error {
@@ -586,10 +612,13 @@ func (o SelectOptions) IsMultiple() bool {
// -------------------------------------------------------------------
type JsonOptions struct {
MaxSize int `form:"maxSize" json:"maxSize"`
}
func (o JsonOptions) Validate() error {
return nil
return validation.ValidateStruct(&o,
validation.Field(&o.MaxSize, validation.Required, validation.Min(1)),
)
}
// -------------------------------------------------------------------
@@ -597,10 +626,10 @@ func (o JsonOptions) Validate() error {
var _ MultiValuer = (*FileOptions)(nil)
type FileOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
Thumbs []string `form:"thumbs" json:"thumbs"`
MaxSelect int `form:"maxSelect" json:"maxSelect"`
MaxSize int `form:"maxSize" json:"maxSize"`
Protected bool `form:"protected" json:"protected"`
}
@@ -645,7 +674,8 @@ type RelationOptions struct {
// If nil no limits are applied.
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
// DisplayFields is optional slice of collection field names used for UI purposes.
// Deprecated: This field is no-op and will be removed in future versions.
// Instead use the individula SchemaField.Presentable option for each field in the relation collection.
DisplayFields []string `form:"displayFields" json:"displayFields"`
}
+91 -52
View File
@@ -129,19 +129,19 @@ func TestSchemaFieldColDefinition(t *testing.T) {
func TestSchemaFieldString(t *testing.T) {
f := schema.SchemaField{
Id: "abc",
Name: "test",
Type: schema.FieldTypeText,
Required: true,
Unique: false,
System: true,
Id: "abc",
Name: "test",
Type: schema.FieldTypeText,
Required: true,
Presentable: true,
System: true,
Options: &schema.TextOptions{
Pattern: "test",
},
}
result := f.String()
expected := `{"system":true,"id":"abc","name":"test","type":"text","required":true,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`
expected := `{"system":true,"id":"abc","name":"test","type":"text","required":true,"presentable":true,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`
if result != expected {
t.Errorf("Expected \n%v, got \n%v", expected, result)
@@ -156,19 +156,19 @@ func TestSchemaFieldMarshalJSON(t *testing.T) {
// empty
{
schema.SchemaField{},
`{"system":false,"id":"","name":"","type":"","required":false,"unique":false,"options":null}`,
`{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
},
// without defined options
{
schema.SchemaField{
Id: "abc",
Name: "test",
Type: schema.FieldTypeText,
Required: true,
Unique: false,
System: true,
Id: "abc",
Name: "test",
Type: schema.FieldTypeText,
Required: true,
Presentable: true,
System: true,
},
`{"system":true,"id":"abc","name":"test","type":"text","required":true,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
`{"system":true,"id":"abc","name":"test","type":"text","required":true,"presentable":true,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
},
// with defined options
{
@@ -182,7 +182,7 @@ func TestSchemaFieldMarshalJSON(t *testing.T) {
Pattern: "test",
},
},
`{"system":true,"id":"","name":"test","type":"text","required":true,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
`{"system":true,"id":"","name":"test","type":"text","required":true,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
},
}
@@ -207,32 +207,32 @@ func TestSchemaFieldUnmarshalJSON(t *testing.T) {
{
nil,
true,
`{"system":false,"id":"","name":"","type":"","required":false,"unique":false,"options":null}`,
`{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
},
{
[]byte{},
true,
`{"system":false,"id":"","name":"","type":"","required":false,"unique":false,"options":null}`,
`{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
},
{
[]byte(`{"system": true}`),
true,
`{"system":true,"id":"","name":"","type":"","required":false,"unique":false,"options":null}`,
`{"system":true,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
},
{
[]byte(`{"invalid"`),
true,
`{"system":false,"id":"","name":"","type":"","required":false,"unique":false,"options":null}`,
`{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
},
{
[]byte(`{"type":"text","system":true}`),
false,
`{"system":true,"id":"","name":"","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
`{"system":true,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
},
{
[]byte(`{"type":"text","options":{"pattern":"test"}}`),
false,
`{"system":false,"id":"","name":"","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
`{"system":false,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
},
}
@@ -470,72 +470,72 @@ func TestSchemaFieldInitOptions(t *testing.T) {
{
schema.SchemaField{},
true,
`{"system":false,"id":"","name":"","type":"","required":false,"unique":false,"options":null}`,
`{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
},
{
schema.SchemaField{Type: "unknown"},
true,
`{"system":false,"id":"","name":"","type":"unknown","required":false,"unique":false,"options":null}`,
`{"system":false,"id":"","name":"","type":"unknown","required":false,"presentable":false,"unique":false,"options":null}`,
},
{
schema.SchemaField{Type: schema.FieldTypeText},
false,
`{"system":false,"id":"","name":"","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
`{"system":false,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeNumber},
false,
`{"system":false,"id":"","name":"","type":"number","required":false,"unique":false,"options":{"min":null,"max":null}}`,
`{"system":false,"id":"","name":"","type":"number","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"noDecimal":false}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeBool},
false,
`{"system":false,"id":"","name":"","type":"bool","required":false,"unique":false,"options":{}}`,
`{"system":false,"id":"","name":"","type":"bool","required":false,"presentable":false,"unique":false,"options":{}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeEmail},
false,
`{"system":false,"id":"","name":"","type":"email","required":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
`{"system":false,"id":"","name":"","type":"email","required":false,"presentable":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeUrl},
false,
`{"system":false,"id":"","name":"","type":"url","required":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
`{"system":false,"id":"","name":"","type":"url","required":false,"presentable":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeEditor},
false,
`{"system":false,"id":"","name":"","type":"editor","required":false,"unique":false,"options":{}}`,
`{"system":false,"id":"","name":"","type":"editor","required":false,"presentable":false,"unique":false,"options":{"convertUrls":false}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeDate},
false,
`{"system":false,"id":"","name":"","type":"date","required":false,"unique":false,"options":{"min":"","max":""}}`,
`{"system":false,"id":"","name":"","type":"date","required":false,"presentable":false,"unique":false,"options":{"min":"","max":""}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeSelect},
false,
`{"system":false,"id":"","name":"","type":"select","required":false,"unique":false,"options":{"maxSelect":0,"values":null}}`,
`{"system":false,"id":"","name":"","type":"select","required":false,"presentable":false,"unique":false,"options":{"maxSelect":0,"values":null}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeJson},
false,
`{"system":false,"id":"","name":"","type":"json","required":false,"unique":false,"options":{}}`,
`{"system":false,"id":"","name":"","type":"json","required":false,"presentable":false,"unique":false,"options":{"maxSize":0}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeFile},
false,
`{"system":false,"id":"","name":"","type":"file","required":false,"unique":false,"options":{"maxSelect":0,"maxSize":0,"mimeTypes":null,"thumbs":null,"protected":false}}`,
`{"system":false,"id":"","name":"","type":"file","required":false,"presentable":false,"unique":false,"options":{"mimeTypes":null,"thumbs":null,"maxSelect":0,"maxSize":0,"protected":false}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeRelation},
false,
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"collectionId":"","cascadeDelete":false,"minSelect":null,"maxSelect":null,"displayFields":null}}`,
`{"system":false,"id":"","name":"","type":"relation","required":false,"presentable":false,"unique":false,"options":{"collectionId":"","cascadeDelete":false,"minSelect":null,"maxSelect":null,"displayFields":null}}`,
},
{
schema.SchemaField{Type: schema.FieldTypeUser},
false,
`{"system":false,"id":"","name":"","type":"user","required":false,"unique":false,"options":{"maxSelect":0,"cascadeDelete":false}}`,
`{"system":false,"id":"","name":"","type":"user","required":false,"presentable":false,"unique":false,"options":{"maxSelect":0,"cascadeDelete":false}}`,
},
{
schema.SchemaField{
@@ -543,21 +543,23 @@ func TestSchemaFieldInitOptions(t *testing.T) {
Options: &schema.TextOptions{Pattern: "test"},
},
false,
`{"system":false,"id":"","name":"","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
`{"system":false,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
},
}
for i, s := range scenarios {
err := s.field.InitOptions()
t.Run(fmt.Sprintf("s%d_%s", i, s.field.Type), func(t *testing.T) {
err := s.field.InitOptions()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, err)
}
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected %v, got %v (%v)", s.expectError, hasErr, err)
}
if s.field.String() != s.expectJson {
t.Errorf("(%d), Expected %v, got %v", i, s.expectJson, s.field.String())
}
if s.field.String() != s.expectJson {
t.Fatalf(" Expected\n%v\ngot\n%v", s.expectJson, s.field.String())
}
})
}
}
@@ -603,10 +605,15 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeJson}, nil, "null"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "null", "null"},
{schema.SchemaField{Type: schema.FieldTypeJson}, 123, "123"},
{schema.SchemaField{Type: schema.FieldTypeJson}, -123, "-123"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "123", "123"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "-123", "-123"},
{schema.SchemaField{Type: schema.FieldTypeJson}, 123.456, "123.456"},
{schema.SchemaField{Type: schema.FieldTypeJson}, -123.456, "-123.456"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "123.456", "123.456"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "-123.456", "-123.456"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "123.456 abc", `"123.456 abc"`}, // invalid numeric string
{schema.SchemaField{Type: schema.FieldTypeJson}, "-a123", `"-a123"`},
{schema.SchemaField{Type: schema.FieldTypeJson}, true, "true"},
{schema.SchemaField{Type: schema.FieldTypeJson}, "true", "true"},
{schema.SchemaField{Type: schema.FieldTypeJson}, false, "false"},
@@ -1743,8 +1750,12 @@ func TestTextOptionsValidate(t *testing.T) {
}
func TestNumberOptionsValidate(t *testing.T) {
number1 := 10.0
number2 := 20.0
int1 := 10.0
int2 := 20.0
decimal1 := 10.5
decimal2 := 20.5
scenarios := []fieldOptionsScenario{
{
"empty",
@@ -1754,23 +1765,41 @@ func TestNumberOptionsValidate(t *testing.T) {
{
"max - without min",
schema.NumberOptions{
Max: &number1,
Max: &int1,
},
[]string{},
},
{
"max - failure with min",
schema.NumberOptions{
Min: &number2,
Max: &number1,
Min: &int2,
Max: &int1,
},
[]string{"max"},
},
{
"max - success with min",
schema.NumberOptions{
Min: &number1,
Max: &number2,
Min: &int1,
Max: &int2,
},
[]string{},
},
{
"NoDecimal range failure",
schema.NumberOptions{
Min: &decimal1,
Max: &decimal2,
NoDecimal: true,
},
[]string{"min", "max"},
},
{
"NoDecimal range success",
schema.NumberOptions{
Min: &int1,
Max: &int2,
NoDecimal: true,
},
[]string{},
},
@@ -2031,6 +2060,16 @@ func TestJsonOptionsValidate(t *testing.T) {
{
"empty",
schema.JsonOptions{},
[]string{"maxSize"},
},
{
"MaxSize < 0",
schema.JsonOptions{MaxSize: -1},
[]string{"maxSize"},
},
{
"MaxSize > 0",
schema.JsonOptions{MaxSize: 1},
[]string{},
},
}
+7 -7
View File
@@ -286,7 +286,7 @@ func TestSchemaMarshalJSON(t *testing.T) {
t.Fatal(err)
}
expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}},{"system":false,"id":"f2id","name":"test2","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`
expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}},{"system":false,"id":"f2id","name":"test2","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`
if string(result) != expected {
t.Fatalf("Expected %s, got %s", expected, string(result))
@@ -354,7 +354,7 @@ func TestSchemaValue(t *testing.T) {
if err != nil {
t.Fatal(err)
}
expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`
expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`
if v2 != expected {
t.Fatalf("Expected %v, got %v", expected, v2)
@@ -377,21 +377,21 @@ func TestSchemaScan(t *testing.T) {
{`[{}]`, true, `[]`},
// unknown field type
{
`[{"system":false,"id":"123","name":"test1","type":"unknown","required":false,"unique":false}]`,
`[{"system":false,"id":"123","name":"test1","type":"unknown","required":false,"presentable":false,"unique":false}]`,
true,
`[]`,
},
// without options
{
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false}]`,
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false}]`,
false,
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
},
// with options
{
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
false,
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
},
}
+35 -1
View File
@@ -63,6 +63,8 @@ type Settings struct {
InstagramAuth AuthProviderConfig `form:"instagramAuth" json:"instagramAuth"`
VKAuth AuthProviderConfig `form:"vkAuth" json:"vkAuth"`
YandexAuth AuthProviderConfig `form:"yandexAuth" json:"yandexAuth"`
PatreonAuth AuthProviderConfig `form:"patreonAuth" json:"patreonAuth"`
MailcowAuth AuthProviderConfig `form:"mailcowAuth" json:"mailcowAuth"`
}
// New creates and returns a new default Settings instance.
@@ -80,6 +82,7 @@ func New() *Settings {
},
Logs: LogsConfig{
MaxDays: 5,
LogIp: true,
},
Smtp: SmtpConfig{
Enabled: false,
@@ -187,6 +190,12 @@ func New() *Settings {
YandexAuth: AuthProviderConfig{
Enabled: false,
},
PatreonAuth: AuthProviderConfig{
Enabled: false,
},
MailcowAuth: AuthProviderConfig{
Enabled: false,
},
}
}
@@ -230,6 +239,8 @@ func (s *Settings) Validate() error {
validation.Field(&s.InstagramAuth),
validation.Field(&s.VKAuth),
validation.Field(&s.YandexAuth),
validation.Field(&s.PatreonAuth),
validation.Field(&s.MailcowAuth),
)
}
@@ -296,6 +307,8 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.InstagramAuth.ClientSecret,
&clone.VKAuth.ClientSecret,
&clone.YandexAuth.ClientSecret,
&clone.PatreonAuth.ClientSecret,
&clone.MailcowAuth.ClientSecret,
}
// mask all sensitive fields
@@ -336,6 +349,8 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
auth.NameInstagram: s.InstagramAuth,
auth.NameVK: s.VKAuth,
auth.NameYandex: s.YandexAuth,
auth.NamePatreon: s.PatreonAuth,
auth.NameMailcow: s.MailcowAuth,
}
}
@@ -371,6 +386,12 @@ type SmtpConfig struct {
// When set to false StartTLS command is send, leaving the server
// to decide whether to upgrade the connection or not.
Tls bool `form:"tls" json:"tls"`
// LocalName is optional domain name or IP address used for the
// EHLO/HELO exchange (if not explicitly set, defaults to "localhost").
//
// This is required only by some SMTP servers, such as Gmail SMTP-relay.
LocalName string `form:"localName" json:"localName"`
}
// Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface.
@@ -393,6 +414,7 @@ func (c SmtpConfig) Validate() error {
// validation.When(c.Enabled, validation.Required),
validation.In(mailer.SmtpAuthLogin, mailer.SmtpAuthPlain),
),
validation.Field(&c.LocalName, is.Host),
)
}
@@ -577,7 +599,9 @@ func (t EmailTemplate) Resolve(
// -------------------------------------------------------------------
type LogsConfig struct {
MaxDays int `form:"maxDays" json:"maxDays"`
MaxDays int `form:"maxDays" json:"maxDays"`
MinLevel int `form:"minLevel" json:"minLevel"`
LogIp bool `form:"logIp" json:"logIp"`
}
// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface.
@@ -596,6 +620,8 @@ type AuthProviderConfig struct {
AuthUrl string `form:"authUrl" json:"authUrl"`
TokenUrl string `form:"tokenUrl" json:"tokenUrl"`
UserApiUrl string `form:"userApiUrl" json:"userApiUrl"`
DisplayName string `form:"displayName" json:"displayName"`
PKCE *bool `form:"pkce" json:"pkce"`
}
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
@@ -635,6 +661,14 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
provider.SetTokenUrl(c.TokenUrl)
}
if c.DisplayName != "" {
provider.SetDisplayName(c.DisplayName)
}
if c.PKCE != nil {
provider.SetPKCE(*c.PKCE)
}
return nil
}
+49
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestSettingsValidate(t *testing.T) {
@@ -73,6 +74,10 @@ func TestSettingsValidate(t *testing.T) {
s.VKAuth.ClientId = ""
s.YandexAuth.Enabled = true
s.YandexAuth.ClientId = ""
s.PatreonAuth.Enabled = true
s.PatreonAuth.ClientId = ""
s.MailcowAuth.Enabled = true
s.MailcowAuth.ClientId = ""
// check if Validate() is triggering the members validate methods.
err := s.Validate()
@@ -114,6 +119,8 @@ func TestSettingsValidate(t *testing.T) {
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
}
errBytes, _ := json.Marshal(err)
@@ -187,6 +194,10 @@ func TestSettingsMerge(t *testing.T) {
s2.VKAuth.ClientId = "vk_test"
s2.YandexAuth.Enabled = true
s2.YandexAuth.ClientId = "yandex_test"
s2.PatreonAuth.Enabled = true
s2.PatreonAuth.ClientId = "patreon_test"
s2.MailcowAuth.Enabled = true
s2.MailcowAuth.ClientId = "mailcow_test"
if err := s1.Merge(s2); err != nil {
t.Fatal(err)
@@ -277,6 +288,8 @@ func TestSettingsRedactClone(t *testing.T) {
s1.InstagramAuth.ClientSecret = testSecret
s1.VKAuth.ClientSecret = testSecret
s1.YandexAuth.ClientSecret = testSecret
s1.PatreonAuth.ClientSecret = testSecret
s1.MailcowAuth.ClientSecret = testSecret
s1Bytes, err := json.Marshal(s1)
if err != nil {
@@ -335,6 +348,8 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
s.InstagramAuth.ClientId = "instagram_test"
s.VKAuth.ClientId = "vk_test"
s.YandexAuth.ClientId = "yandex_test"
s.PatreonAuth.ClientId = "patreon_test"
s.MailcowAuth.ClientId = "mailcow_test"
result := s.NamedAuthProviderConfigs()
@@ -366,6 +381,8 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
`"instagram":{"enabled":false,"clientId":"instagram_test"`,
`"vk":{"enabled":false,"clientId":"vk_test"`,
`"yandex":{"enabled":false,"clientId":"yandex_test"`,
`"patreon":{"enabled":false,"clientId":"patreon_test"`,
`"mailcow":{"enabled":false,"clientId":"mailcow_test"`,
}
for _, p := range expectedParts {
if !strings.Contains(encodedStr, p) {
@@ -477,6 +494,26 @@ func TestSmtpConfigValidate(t *testing.T) {
},
false,
},
// invalid ehlo/helo name
{
settings.SmtpConfig{
Enabled: true,
Host: "example.com",
Port: 100,
LocalName: "invalid!",
},
true,
},
// valid ehlo/helo name
{
settings.SmtpConfig{
Enabled: true,
Host: "example.com",
Port: 100,
LocalName: "example.com",
},
false,
},
}
for i, scenario := range scenarios {
@@ -905,6 +942,8 @@ func TestAuthProviderConfigValidate(t *testing.T) {
Enabled: true,
ClientId: "test",
ClientSecret: "test",
DisplayName: "test",
PKCE: types.Pointer(true),
AuthUrl: "https://example.com",
TokenUrl: "https://example.com",
UserApiUrl: "https://example.com",
@@ -942,6 +981,8 @@ func TestAuthProviderConfigSetupProvider(t *testing.T) {
AuthUrl: "test_AuthUrl",
UserApiUrl: "test_UserApiUrl",
TokenUrl: "test_TokenUrl",
DisplayName: "test_DisplayName",
PKCE: types.Pointer(true),
}
if err := c2.SetupProvider(provider); err != nil {
t.Error(err)
@@ -966,4 +1007,12 @@ func TestAuthProviderConfigSetupProvider(t *testing.T) {
if provider.TokenUrl() != c2.TokenUrl {
t.Fatalf("Expected TokenUrl %s, got %s", c2.TokenUrl, provider.TokenUrl())
}
if provider.DisplayName() != c2.DisplayName {
t.Fatalf("Expected DisplayName %s, got %s", c2.DisplayName, provider.DisplayName())
}
if provider.PKCE() != *c2.PKCE {
t.Fatalf("Expected PKCE %v, got %v", *c2.PKCE, provider.PKCE())
}
}
+21 -9
View File
@@ -12,7 +12,7 @@ import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"os"
"path/filepath"
@@ -105,12 +105,9 @@ func (p *plugin) updateCmd() *cobra.Command {
var withBackup bool
command := &cobra.Command{
Use: "update",
Short: "Automatically updates the current PocketBase executable with the latest available version",
// @todo remove after logs generalization
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
Use: "update",
Short: "Automatically updates the current PocketBase executable with the latest available version",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
var needConfirm bool
if isMaybeRunningInDocker() {
@@ -221,8 +218,13 @@ func (p *plugin) update(withBackup bool) error {
}
tryToRevertExecChanges := func() {
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
log.Println(revertErr)
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
p.app.Logger().Debug(
"Failed to revert executable",
slog.String("old", renamedOldExec),
slog.String("new", oldExec),
slog.String("error", revertErr.Error()),
)
}
}
@@ -245,6 +247,16 @@ func (p *plugin) update(withBackup bool) error {
color.HiBlack("---")
color.Green("Update completed successfully! You can start the executable as usual.")
// print the release notes
if latest.Body != "" {
fmt.Print("\n")
color.Cyan("Here is a list with some of the %s changes:", latest.Tag)
// remove the update command note to avoid "stuttering"
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./pocketbase update`._", "", 1))
color.Cyan(releaseNotes)
fmt.Print("\n")
}
return nil
}
+4 -3
View File
@@ -6,19 +6,20 @@ import (
)
type releaseAsset struct {
Id int `json:"id"`
Name string `json:"name"`
Size int `json:"size"`
DownloadUrl string `json:"browser_download_url"`
Id int `json:"id"`
Size int `json:"size"`
}
type release struct {
Id int `json:"id"`
Name string `json:"name"`
Tag string `json:"tag_name"`
Published string `json:"published_at"`
Url string `json:"html_url"`
Body string `json:"body"`
Assets []*releaseAsset `json:"assets"`
Id int `json:"id"`
}
// findAssetBySuffix returns the first available asset containing the specified suffix.
+83 -7
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
@@ -22,6 +23,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tokens"
@@ -31,7 +33,9 @@ import (
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cobra"
)
@@ -111,10 +115,18 @@ func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
pr := goja.MustCompile("", "{("+handler+").apply(undefined)}", true)
err := scheduler.Add(jobId, cronExpr, func() {
executors.run(func(executor *goja.Runtime) error {
err := executors.run(func(executor *goja.Runtime) error {
_, err := executor.RunProgram(pr)
return err
})
if err != nil {
app.Logger().Debug(
"[cronAdd] failed to execute cron job",
slog.String("jobId", jobId),
slog.String("error", err.Error()),
)
}
})
if err != nil {
panic("[cronAdd] failed to register cron job " + jobId + ": " + err.Error())
@@ -272,6 +284,21 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]echo.M
func baseBinds(vm *goja.Runtime) {
vm.SetFieldNameMapper(FieldMapper{})
vm.Set("readerToString", func(r io.Reader, maxBytes int) (string, error) {
if maxBytes == 0 {
maxBytes = rest.DefaultMaxMemory
}
limitReader := io.LimitReader(r, int64(maxBytes))
bodyBytes, readErr := io.ReadAll(limitReader)
if readErr != nil {
return "", readErr
}
return string(bodyBytes), nil
})
vm.Set("arrayOf", func(model any) any {
mt := reflect.TypeOf(model)
st := reflect.SliceOf(mt)
@@ -390,6 +417,16 @@ func baseBinds(vm *goja.Runtime) {
return instanceValue
})
vm.Set("Cookie", func(call goja.ConstructorCall) *goja.Object {
instance := &http.Cookie{}
return structConstructor(vm, call, instance)
})
vm.Set("SubscriptionMessage", func(call goja.ConstructorCall) *goja.Object {
instance := &subscriptions.Message{}
return structConstructor(vm, call, instance)
})
}
func dbxBinds(vm *goja.Runtime) {
@@ -415,6 +452,19 @@ func dbxBinds(vm *goja.Runtime) {
obj.Set("notBetween", dbx.NotBetween)
}
func mailsBinds(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$mails", obj)
// admin
obj.Set("sendAdminPasswordReset", mails.SendAdminPasswordReset)
// record
obj.Set("sendRecordPasswordReset", mails.SendRecordPasswordReset)
obj.Set("sendRecordVerification", mails.SendRecordVerification)
obj.Set("sendRecordChangeEmail", mails.SendRecordChangeEmail)
}
func tokensBinds(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$tokens", obj)
@@ -436,6 +486,14 @@ func securityBinds(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$security", obj)
// crypto
obj.Set("md5", security.MD5)
obj.Set("sha256", security.SHA256)
obj.Set("sha512", security.SHA512)
obj.Set("hs256", security.HS256)
obj.Set("hs512", security.HS512)
obj.Set("equal", security.Equal)
// random
obj.Set("randomString", security.RandomString)
obj.Set("randomStringWithAlphabet", security.RandomStringWithAlphabet)
@@ -443,8 +501,12 @@ func securityBinds(vm *goja.Runtime) {
obj.Set("pseudorandomStringWithAlphabet", security.PseudorandomStringWithAlphabet)
// jwt
obj.Set("parseUnverifiedJWT", security.ParseUnverifiedJWT)
obj.Set("parseJWT", security.ParseJWT)
obj.Set("parseUnverifiedJWT", func(token string) (map[string]any, error) {
return security.ParseUnverifiedJWT(token)
})
obj.Set("parseJWT", func(token string, verificationKey string) (map[string]any, error) {
return security.ParseJWT(token, verificationKey)
})
obj.Set("createJWT", security.NewJWT)
// encryption
@@ -495,7 +557,8 @@ func osBinds(vm *goja.Runtime) {
vm.Set("$os", obj)
obj.Set("args", os.Args)
obj.Set("exec", exec.Command)
obj.Set("exec", exec.Command) // @deprecated
obj.Set("cmd", exec.Command)
obj.Set("exit", os.Exit)
obj.Set("getenv", os.Getenv)
obj.Set("dirFS", os.DirFS)
@@ -544,6 +607,7 @@ func apisBinds(vm *goja.Runtime) {
})
// middlewares
obj.Set("requireGuestOnly", apis.RequireGuestOnly)
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
obj.Set("requireAdminAuth", apis.RequireAdminAuth)
obj.Set("requireAdminAuthOnlyIfAny", apis.RequireAdminAuthOnlyIfAny)
@@ -570,9 +634,11 @@ func httpClientBinds(vm *goja.Runtime) {
vm.Set("$http", obj)
type sendResult struct {
StatusCode int
Raw string
Json any
StatusCode int `json:"statusCode"`
Headers map[string][]string `json:"headers"`
Cookies map[string]*http.Cookie `json:"cookies"`
Raw string `json:"raw"`
Json any `json:"json"`
}
type sendConfig struct {
@@ -643,9 +709,19 @@ func httpClientBinds(vm *goja.Runtime) {
result := &sendResult{
StatusCode: res.StatusCode,
Headers: map[string][]string{},
Cookies: map[string]*http.Cookie{},
Raw: string(bodyRaw),
}
for k, v := range res.Header {
result.Headers[k] = v
}
for _, v := range res.Cookies() {
result.Cookies[v.Name] = v
}
if len(result.Raw) != 0 {
// try as map
result.Json = map[string]any{}
+243 -38
View File
@@ -24,6 +24,7 @@ import (
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T) {
@@ -45,7 +46,90 @@ func TestBaseBindsCount(t *testing.T) {
vm := goja.New()
baseBinds(vm)
testBindsCount(vm, "this", 13, t)
testBindsCount(vm, "this", 16, t)
}
func TestBaseBindsReaderToString(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
vm.Set("reader", strings.NewReader("test"))
_, err := vm.RunString(`
let result = readerToString(reader)
if (result != "test") {
throw new Error('Expected "test", got ' + result);
}
`)
if err != nil {
t.Fatal(err)
}
}
func TestBaseBindsCookie(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
_, err := vm.RunString(`
const cookie = new Cookie({
name: "example_name",
value: "example_value",
path: "/example_path",
domain: "example.com",
maxAge: 10,
secure: true,
httpOnly: true,
sameSite: 3,
});
const result = cookie.string();
const expected = "example_name=example_value; Path=/example_path; Domain=example.com; Max-Age=10; HttpOnly; Secure; SameSite=Strict";
if (expected != result) {
throw new("Expected \n" + expected + "\ngot\n" + result);
}
`)
if err != nil {
t.Fatal(err)
}
}
func TestBaseBindsSubscriptionMessage(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
vm.Set("bytesToString", func(b []byte) string {
return string(b)
})
_, err := vm.RunString(`
const payload = {
name: "test",
data: '{"test":123}'
}
const result = new SubscriptionMessage(payload);
if (result.name != payload.name) {
throw new("Expected name " + payload.name + ", got " + result.name);
}
if (bytesToString(result.data) != payload.data) {
throw new("Expected data '" + payload.data + "', got '" + bytesToString(result.data) + "'");
}
`)
if err != nil {
t.Fatal(err)
}
}
func TestBaseBindsRecord(t *testing.T) {
@@ -484,6 +568,60 @@ func TestDbxBinds(t *testing.T) {
}
}
func TestMailsBindsCount(t *testing.T) {
vm := goja.New()
mailsBinds(vm)
testBindsCount(vm, "$mails", 4, t)
}
func TestMailsBinds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
admin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
record, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
vm := goja.New()
baseBinds(vm)
mailsBinds(vm)
vm.Set("$app", app)
vm.Set("admin", admin)
vm.Set("record", record)
_, vmErr := vm.RunString(`
$mails.sendAdminPasswordReset($app, admin);
if (!$app.testMailer.lastMessage.html.includes("/_/#/confirm-password-reset/")) {
throw new Error("Expected admin password reset email")
}
$mails.sendRecordPasswordReset($app, record);
if (!$app.testMailer.lastMessage.html.includes("/_/#/auth/confirm-password-reset/")) {
throw new Error("Expected record password reset email")
}
$mails.sendRecordVerification($app, record);
if (!$app.testMailer.lastMessage.html.includes("/_/#/auth/confirm-verification/")) {
throw new Error("Expected record verification email")
}
$mails.sendRecordChangeEmail($app, record, "new@example.com");
if (!$app.testMailer.lastMessage.html.includes("/_/#/auth/confirm-email-change/")) {
throw new Error("Expected record email change email")
}
`)
if vmErr != nil {
t.Fatal(vmErr)
}
}
func TestTokensBindsCount(t *testing.T) {
vm := goja.New()
tokensBinds(vm)
@@ -506,11 +644,11 @@ func TestTokensBinds(t *testing.T) {
}
vm := goja.New()
baseBinds(vm)
tokensBinds(vm)
vm.Set("$app", app)
vm.Set("admin", admin)
vm.Set("record", record)
baseBinds(vm)
tokensBinds(vm)
sceneraios := []struct {
js string
@@ -568,7 +706,44 @@ func TestSecurityBindsCount(t *testing.T) {
vm := goja.New()
securityBinds(vm)
testBindsCount(vm, "$security", 9, t)
testBindsCount(vm, "$security", 15, t)
}
func TestSecurityCryptoBinds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
securityBinds(vm)
sceneraios := []struct {
js string
expected string
}{
{`$security.md5("123")`, "202cb962ac59075b964b07152d234b70"},
{`$security.sha256("123")`, "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"},
{`$security.sha512("123")`, "3c9909afec25354d551dae21590bb26e38d53f2173b8d3dc3eee4c047e7ab1c1eb8b85103e3be7ba613b31bb5c9c36214dc9f14a42fd7a2fdb84856bca5c44c2"},
{`$security.hs256("hello", "test")`, "f151ea24bda91a18e89b8bb5793ef324b2a02133cce15a28a719acbd2e58a986"},
{`$security.hs512("hello", "test")`, "44f280e11103e295c26cd61dd1cdd8178b531b860466867c13b1c37a26b6389f8af110efbe0bb0717b9d9c87f6fe1c97b3b1690936578890e5669abf279fe7fd"},
{`$security.equal("abc", "abc")`, "true"},
{`$security.equal("abc", "abcd")`, "false"},
}
for _, s := range sceneraios {
t.Run(s.js, func(t *testing.T) {
result, err := vm.RunString(s.js)
if err != nil {
t.Fatalf("Failed to execute js script, got %v", err)
}
v := cast.ToString(result.Export())
if v != s.expected {
t.Fatalf("Expected %v \ngot \n%v", s.expected, v)
}
})
}
}
func TestSecurityRandomStringBinds(t *testing.T) {
@@ -590,16 +765,18 @@ func TestSecurityRandomStringBinds(t *testing.T) {
}
for _, s := range sceneraios {
result, err := vm.RunString(s.js)
if err != nil {
t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err)
}
t.Run(s.js, func(t *testing.T) {
result, err := vm.RunString(s.js)
if err != nil {
t.Fatalf("Failed to execute js script, got %v", err)
}
v, _ := result.Export().(string)
v, _ := result.Export().(string)
if len(v) != s.length {
t.Fatalf("[%s] Expected %d length string, \ngot \n%v", s.js, s.length, v)
}
if len(v) != s.length {
t.Fatalf("Expected %d length string, \ngot \n%v", s.length, v)
}
})
}
}
@@ -607,39 +784,59 @@ func TestSecurityJWTBinds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
securityBinds(vm)
sceneraios := []struct {
js string
expected string
name string
js string
}{
{
`$security.parseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY")`,
`{"name":"John Doe","sub":"1234567890"}`,
"$security.parseUnverifiedJWT",
`
const result = $security.parseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY")
if (result.name != "John Doe") {
throw new Error("Expected result.name 'John Doe', got " + result.name)
}
if (result.sub != "1234567890") {
throw new Error("Expected result.sub '1234567890', got " + result.sub)
}
`,
},
{
`$security.parseJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY", "test")`,
`{"name":"John Doe","sub":"1234567890"}`,
"$security.parseJWT",
`
const result = $security.parseJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY", "test")
if (result.name != "John Doe") {
throw new Error("Expected result.name 'John Doe', got " + result.name)
}
if (result.sub != "1234567890") {
throw new Error("Expected result.sub '1234567890', got " + result.sub)
}
`,
},
{
`$security.createJWT({"exp": 123}, "test", 0)`, // overwrite the exp claim for static token
`"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEyM30.7gbv7w672gApdBRASI6OniCtKwkKjhieSxsr6vxSrtw"`,
"$security.createJWT",
`
// overwrite the exp claim for static token
const result = $security.createJWT({"exp": 123}, "test", 0)
const expected = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEyM30.7gbv7w672gApdBRASI6OniCtKwkKjhieSxsr6vxSrtw";
if (result != expected) {
throw new Error("Expected token \n" + expected + ", got \n" + result)
}
`,
},
}
for _, s := range sceneraios {
result, err := vm.RunString(s.js)
if err != nil {
t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err)
}
t.Run(s.name, func(t *testing.T) {
vm := goja.New()
baseBinds(vm)
securityBinds(vm)
raw, _ := json.Marshal(result.Export())
if string(raw) != s.expected {
t.Fatalf("[%s] Expected \n%s, \ngot \n%s", s.js, s.expected, raw)
}
_, err := vm.RunString(s.js)
if err != nil {
t.Fatalf("Failed to execute js script, got %v", err)
}
})
}
}
@@ -737,7 +934,7 @@ func TestApisBindsCount(t *testing.T) {
apisBinds(vm)
testBindsCount(vm, "this", 6, t)
testBindsCount(vm, "$apis", 11, t)
testBindsCount(vm, "$apis", 12, t)
}
func TestApisBindsApiError(t *testing.T) {
@@ -907,9 +1104,9 @@ func TestHttpClientBindsCount(t *testing.T) {
func TestHttpClientBindsSend(t *testing.T) {
// start a test server
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
if req.URL.Query().Get("testError") != "" {
rw.WriteHeader(400)
res.WriteHeader(400)
return
}
@@ -936,10 +1133,14 @@ func TestHttpClientBindsSend(t *testing.T) {
"body": string(bodyRaw),
}
// add custom headers and cookies
res.Header().Add("X-Custom", "custom_header")
res.Header().Add("Set-Cookie", "sessionId=123456")
infoRaw, _ := json.Marshal(info)
// write back the submitted request
rw.Write(infoRaw)
res.Write(infoRaw)
}))
defer server.Close()
@@ -1006,6 +1207,8 @@ func TestHttpClientBindsSend(t *testing.T) {
}],
[test1, {
"statusCode": "200",
"headers.X-Custom.0": "custom_header",
"cookies.sessionId.value": "123456",
"json.method": "POST",
"json.headers.header1": "123",
"json.headers.header2": "456",
@@ -1014,6 +1217,8 @@ func TestHttpClientBindsSend(t *testing.T) {
}],
[test2, {
"statusCode": "200",
"headers.X-Custom.0": "custom_header",
"cookies.sessionId.value": "123456",
"json.method": "GET",
"json.headers.content_type": "text/plain",
}],
@@ -1248,5 +1453,5 @@ func TestOsBindsCount(t *testing.T) {
vm := goja.New()
osBinds(vm)
testBindsCount(vm, "$os", 16, t)
testBindsCount(vm, "$os", 17, t)
}
File diff suppressed because it is too large. Load diff
+176 -34
View File
@@ -29,7 +29,7 @@ const heading = `
*
* ` + "```" + `js
* // prints "Hello world!" on every 30 minutes
* cronAdd("hello", "*\/30 * * * *", (c) => {
* cronAdd("hello", "*\/30 * * * *", () => {
* console.log("Hello world!")
* })
* ` + "```" + `
@@ -144,8 +144,19 @@ declare function routerPre(...middlewares: Array<string|echo.MiddlewareFunc>): v
*/
declare var __hooks: string
// skip on* hook methods as they are registered via the global on* method
type appWithoutHooks = Omit<pocketbase.PocketBase, ` + "`on${string}`" + `>
// Utility type to exclude the on* hook methods from a type
// (hooks are separately generated as global methods).
//
// See https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
type excludeHooks<Type> = {
[Property in keyof Type as Exclude<Property, ` + "`on${string}`" + `>]: Type[Property]
};
// core.App without the on* hook methods
type CoreApp = excludeHooks<ORIGINAL_CORE_APP>
// pocketbase.PocketBase without the on* hook methods
type PocketBase = excludeHooks<ORIGINAL_POCKETBASE>
/**
* ` + "`$app`" + ` is the current running PocketBase instance that is globally
@@ -156,7 +167,7 @@ type appWithoutHooks = Omit<pocketbase.PocketBase, ` + "`on${string}`" + `>
* @namespace
* @group PocketBase
*/
declare var $app: appWithoutHooks
declare var $app: PocketBase
/**
* ` + "`$template`" + ` is a global helper to load and cache HTML templates on the fly.
@@ -180,6 +191,24 @@ declare var $app: appWithoutHooks
*/
declare var $template: template.Registry
/**
* readerToString reads the content of the specified io.Reader until
* EOF or maxBytes are reached.
*
* If maxBytes is not specified it will read up to 32MB.
*
* Note that after this call the reader can't be used anymore.
*
* Example:
*
* ` + "```" + `js
* const rawBody = readerToString(c.request().body)
* ` + "```" + `
*
* @group PocketBase
*/
declare function readerToString(reader: any, maxBytes?: number): string;
/**
* arrayOf creates a placeholder array of the specified models.
* Usually used to populate DB result into an array of models.
@@ -400,7 +429,7 @@ declare class DateTime implements types.DateTime {
interface ValidationError extends ozzo_validation.Error{} // merge
/**
* ValidationError defines a single formatted data validation error,
* usually used as part of a error response.
* usually used as part of an error response.
*
* ` + "```" + `js
* new ValidationError("invalid_title", "Title is not valid")
@@ -421,6 +450,57 @@ declare class Dao implements daos.Dao {
constructor(concurrentDB?: dbx.Builder, nonconcurrentDB?: dbx.Builder)
}
interface Cookie extends http.Cookie{} // merge
/**
* A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an
* HTTP response.
*
* Example:
*
* ` + "```" + `js
* routerAdd("POST", "/example", (c) => {
* c.setCookie(new Cookie({
* name: "example_name",
* value: "example_value",
* path: "/",
* domain: "example.com",
* maxAge: 10,
* secure: true,
* httpOnly: true,
* sameSite: 3,
* }))
*
* return c.redirect(200, "/");
* })
* ` + "```" + `
*
* @group PocketBase
*/
declare class Cookie implements http.Cookie {
constructor(options?: Partial<http.Cookie>)
}
interface SubscriptionMessage extends subscriptions.Message{} // merge
/**
* SubscriptionMessage defines a realtime subscription payload.
*
* Example:
*
* ` + "```" + `js
* onRealtimeConnectRequest((e) => {
* e.client.send(new SubscriptionMessage({
* name: "example",
* data: '{"greeting": "Hello world"}'
* }))
* })
* ` + "```" + `
*
* @group PocketBase
*/
declare class SubscriptionMessage implements subscriptions.Message {
constructor(options?: Partial<subscriptions.Message>)
}
// -------------------------------------------------------------------
// dbxBinds
// -------------------------------------------------------------------
@@ -478,6 +558,23 @@ declare namespace $tokens {
let recordFileToken: tokens.newRecordFileToken
}
// -------------------------------------------------------------------
// mailsBinds
// -------------------------------------------------------------------
/**
* ` + "`" + `$mails` + "`" + ` defines helpers to send common
* admins and auth records emails like verification, password reset, etc.
*
* @group PocketBase
*/
declare namespace $mails {
let sendAdminPasswordReset: mails.sendAdminPasswordReset
let sendRecordPasswordReset: mails.sendRecordPasswordReset
let sendRecordVerification: mails.sendRecordVerification
let sendRecordChangeEmail: mails.sendRecordChangeEmail
}
// -------------------------------------------------------------------
// securityBinds
// -------------------------------------------------------------------
@@ -493,11 +590,25 @@ declare namespace $security {
let randomStringWithAlphabet: security.randomStringWithAlphabet
let pseudorandomString: security.pseudorandomString
let pseudorandomStringWithAlphabet: security.pseudorandomStringWithAlphabet
let parseUnverifiedJWT: security.parseUnverifiedJWT
let parseJWT: security.parseJWT
let createJWT: security.newJWT
let encrypt: security.encrypt
let decrypt: security.decrypt
let hs256: security.hs256
let hs512: security.hs512
let equal: security.equal
let md5: security.md5
let sha256: security.sha256
let sha512: security.sha512
let createJWT: security.newJWT
/**
* {@inheritDoc security.parseUnverifiedJWT}
*/
export function parseUnverifiedJWT(token: string): _TygojaDict
/**
* {@inheritDoc security.parseJWT}
*/
export function parseJWT(token: string, verificationKey: string): _TygojaDict
}
// -------------------------------------------------------------------
@@ -555,7 +666,26 @@ declare namespace $filepath {
* @group PocketBase
*/
declare namespace $os {
export let exec: exec.command
/**
* Legacy alias for $os.cmd().
*/
export let exec: exec.command
/**
* Prepares an external OS command.
*
* Example:
*
* ` + "```" + `js
* // prepare the command to execute
* const cmd = $os.cmd('ls', '-sl')
*
* // execute the command and return its standard output as string
* const output = String.fromCharCode(...cmd.output());
* ` + "```" + `
*/
export let cmd: exec.command
export let args: os.args
export let exit: os.exit
export let getenv: os.getenv
@@ -583,7 +713,7 @@ interface AdminLoginForm extends forms.AdminLogin{} // merge
* @group PocketBase
*/
declare class AdminLoginForm implements forms.AdminLogin {
constructor(app: core.App)
constructor(app: CoreApp)
}
interface AdminPasswordResetConfirmForm extends forms.AdminPasswordResetConfirm{} // merge
@@ -592,7 +722,7 @@ interface AdminPasswordResetConfirmForm extends forms.AdminPasswordResetConfirm{
* @group PocketBase
*/
declare class AdminPasswordResetConfirmForm implements forms.AdminPasswordResetConfirm {
constructor(app: core.App)
constructor(app: CoreApp)
}
interface AdminPasswordResetRequestForm extends forms.AdminPasswordResetRequest{} // merge
@@ -601,7 +731,7 @@ interface AdminPasswordResetRequestForm extends forms.AdminPasswordResetRequest{
* @group PocketBase
*/
declare class AdminPasswordResetRequestForm implements forms.AdminPasswordResetRequest {
constructor(app: core.App)
constructor(app: CoreApp)
}
interface AdminUpsertForm extends forms.AdminUpsert{} // merge
@@ -610,7 +740,7 @@ interface AdminUpsertForm extends forms.AdminUpsert{} // merge
* @group PocketBase
*/
declare class AdminUpsertForm implements forms.AdminUpsert {
constructor(app: core.App, admin: models.Admin)
constructor(app: CoreApp, admin: models.Admin)
}
interface AppleClientSecretCreateForm extends forms.AppleClientSecretCreate{} // merge
@@ -619,7 +749,7 @@ interface AppleClientSecretCreateForm extends forms.AppleClientSecretCreate{} //
* @group PocketBase
*/
declare class AppleClientSecretCreateForm implements forms.AppleClientSecretCreate {
constructor(app: core.App)
constructor(app: CoreApp)
}
interface CollectionUpsertForm extends forms.CollectionUpsert{} // merge
@@ -628,7 +758,7 @@ interface CollectionUpsertForm extends forms.CollectionUpsert{} // merge
* @group PocketBase
*/
declare class CollectionUpsertForm implements forms.CollectionUpsert {
constructor(app: core.App, collection: models.Collection)
constructor(app: CoreApp, collection: models.Collection)
}
interface CollectionsImportForm extends forms.CollectionsImport{} // merge
@@ -637,7 +767,7 @@ interface CollectionsImportForm extends forms.CollectionsImport{} // merge
* @group PocketBase
*/
declare class CollectionsImportForm implements forms.CollectionsImport {
constructor(app: core.App)
constructor(app: CoreApp)
}
interface RealtimeSubscribeForm extends forms.RealtimeSubscribe{} // merge
@@ -653,7 +783,7 @@ interface RecordEmailChangeConfirmForm extends forms.RecordEmailChangeConfirm{}
* @group PocketBase
*/
declare class RecordEmailChangeConfirmForm implements forms.RecordEmailChangeConfirm {
constructor(app: core.App, collection: models.Collection)
constructor(app: CoreApp, collection: models.Collection)
}
interface RecordEmailChangeRequestForm extends forms.RecordEmailChangeRequest{} // merge
@@ -662,7 +792,7 @@ interface RecordEmailChangeRequestForm extends forms.RecordEmailChangeRequest{}
* @group PocketBase
*/
declare class RecordEmailChangeRequestForm implements forms.RecordEmailChangeRequest {
constructor(app: core.App, record: models.Record)
constructor(app: CoreApp, record: models.Record)
}
interface RecordOAuth2LoginForm extends forms.RecordOAuth2Login{} // merge
@@ -671,7 +801,7 @@ interface RecordOAuth2LoginForm extends forms.RecordOAuth2Login{} // merge
* @group PocketBase
*/
declare class RecordOAuth2LoginForm implements forms.RecordOAuth2Login {
constructor(app: core.App, collection: models.Collection, optAuthRecord?: models.Record)
constructor(app: CoreApp, collection: models.Collection, optAuthRecord?: models.Record)
}
interface RecordPasswordLoginForm extends forms.RecordPasswordLogin{} // merge
@@ -680,7 +810,7 @@ interface RecordPasswordLoginForm extends forms.RecordPasswordLogin{} // merge
* @group PocketBase
*/
declare class RecordPasswordLoginForm implements forms.RecordPasswordLogin {
constructor(app: core.App, collection: models.Collection)
constructor(app: CoreApp, collection: models.Collection)
}
interface RecordPasswordResetConfirmForm extends forms.RecordPasswordResetConfirm{} // merge
@@ -689,7 +819,7 @@ interface RecordPasswordResetConfirmForm extends forms.RecordPasswordResetConfir
* @group PocketBase
*/
declare class RecordPasswordResetConfirmForm implements forms.RecordPasswordResetConfirm {
constructor(app: core.App, collection: models.Collection)
constructor(app: CoreApp, collection: models.Collection)
}
interface RecordPasswordResetRequestForm extends forms.RecordPasswordResetRequest{} // merge
@@ -698,7 +828,7 @@ interface RecordPasswordResetRequestForm extends forms.RecordPasswordResetReques
* @group PocketBase
*/
declare class RecordPasswordResetRequestForm implements forms.RecordPasswordResetRequest {
constructor(app: core.App, collection: models.Collection)
constructor(app: CoreApp, collection: models.Collection)
}
interface RecordUpsertForm extends forms.RecordUpsert{} // merge
@@ -707,7 +837,7 @@ interface RecordUpsertForm extends forms.RecordUpsert{} // merge
* @group PocketBase
*/
declare class RecordUpsertForm implements forms.RecordUpsert {
constructor(app: core.App, record: models.Record)
constructor(app: CoreApp, record: models.Record)
}
interface RecordVerificationConfirmForm extends forms.RecordVerificationConfirm{} // merge
@@ -716,7 +846,7 @@ interface RecordVerificationConfirmForm extends forms.RecordVerificationConfirm{
* @group PocketBase
*/
declare class RecordVerificationConfirmForm implements forms.RecordVerificationConfirm {
constructor(app: core.App, collection: models.Collection)
constructor(app: CoreApp, collection: models.Collection)
}
interface RecordVerificationRequestForm extends forms.RecordVerificationRequest{} // merge
@@ -725,7 +855,7 @@ interface RecordVerificationRequestForm extends forms.RecordVerificationRequest{
* @group PocketBase
*/
declare class RecordVerificationRequestForm implements forms.RecordVerificationRequest {
constructor(app: core.App, collection: models.Collection)
constructor(app: CoreApp, collection: models.Collection)
}
interface SettingsUpsertForm extends forms.SettingsUpsert{} // merge
@@ -734,7 +864,7 @@ interface SettingsUpsertForm extends forms.SettingsUpsert{} // merge
* @group PocketBase
*/
declare class SettingsUpsertForm implements forms.SettingsUpsert {
constructor(app: core.App)
constructor(app: CoreApp)
}
interface TestEmailSendForm extends forms.TestEmailSend{} // merge
@@ -743,7 +873,7 @@ interface TestEmailSendForm extends forms.TestEmailSend{} // merge
* @group PocketBase
*/
declare class TestEmailSendForm implements forms.TestEmailSend {
constructor(app: core.App)
constructor(app: CoreApp)
}
interface TestS3FilesystemForm extends forms.TestS3Filesystem{} // merge
@@ -752,7 +882,7 @@ interface TestS3FilesystemForm extends forms.TestS3Filesystem{} // merge
* @group PocketBase
*/
declare class TestS3FilesystemForm implements forms.TestS3Filesystem {
constructor(app: core.App)
constructor(app: CoreApp)
}
// -------------------------------------------------------------------
@@ -823,6 +953,7 @@ declare namespace $apis {
*/
export function staticDirectoryHandler(dir: string, indexFallback: boolean): echo.HandlerFunc
let requireGuestOnly: apis.requireGuestOnly
let requireRecordAuth: apis.requireRecordAuth
let requireAdminAuth: apis.requireAdminAuth
let requireAdminAuthOnlyIfAny: apis.requireAdminAuthOnlyIfAny
@@ -857,9 +988,11 @@ declare namespace $http {
* method: "post",
* })
*
* console.log(res.statusCode)
* console.log(res.raw)
* console.log(res.json)
* console.log(res.statusCode) // the response HTTP status code
* console.log(res.headers) // the response headers (eg. res.headers['X-Custom'][0])
* console.log(res.cookies) // the response cookies (eg. res.cookies.sessionId.value)
* console.log(res.raw) // the response body as plain text
* console.log(res.json) // the response body as parsed json array or map
* ` + "```" + `
*/
function send(config: {
@@ -872,9 +1005,11 @@ declare namespace $http {
// deprecated, please use body instead
data?: { [key:string]: any },
}): {
statusCode: number
raw: string
json: any
statusCode: number,
headers: { [key:string]: Array<string> },
cookies: { [key:string]: http.Cookie },
raw: string,
json: any,
};
}
@@ -908,6 +1043,7 @@ func main() {
"github.com/pocketbase/pocketbase/tools/filesystem": {"*"},
"github.com/pocketbase/pocketbase/tools/template": {"*"},
"github.com/pocketbase/pocketbase/tokens": {"*"},
"github.com/pocketbase/pocketbase/mails": {"*"},
"github.com/pocketbase/pocketbase/apis": {"*"},
"github.com/pocketbase/pocketbase/forms": {"*"},
"github.com/pocketbase/pocketbase": {"*"},
@@ -953,6 +1089,12 @@ func main() {
log.Fatal("Failed to get the current docs directory")
}
// replace the original app interfaces with their non-"on*"" hooks equivalents
result = strings.ReplaceAll(result, "core.App", "CoreApp")
result = strings.ReplaceAll(result, "pocketbase.PocketBase", "PocketBase")
result = strings.ReplaceAll(result, "ORIGINAL_CORE_APP", "core.App")
result = strings.ReplaceAll(result, "ORIGINAL_POCKETBASE", "pocketbase.PocketBase")
parentDir := filepath.Dir(filename)
typesFile := filepath.Join(parentDir, "generated", "types.d.ts")
+7 -7
View File
@@ -127,7 +127,7 @@ func Register(app core.App, config Config) error {
})
if err := p.registerMigrations(); err != nil {
return fmt.Errorf("registerHooks: %w", err)
return fmt.Errorf("registerMigrations: %w", err)
}
if err := p.registerHooks(); err != nil {
@@ -161,12 +161,9 @@ func (p *plugin) registerMigrations() error {
dbxBinds(vm)
tokensBinds(vm)
securityBinds(vm)
// note: disallow for now and give the authors of custom SaaS offerings
// some time to adjust their code to avoid eventual security issues
//
// osBinds(vm)
// filepathBinds(vm)
// httpClientBinds(vm)
osBinds(vm)
filepathBinds(vm)
httpClientBinds(vm)
vm.Set("migrate", func(up, down func(db dbx.Builder) error) {
m.AppMigrations.Register(up, down, file)
@@ -236,6 +233,7 @@ func (p *plugin) registerHooks() error {
requireRegistry.Enable(vm)
console.Enable(vm)
process.Enable(vm)
baseBinds(vm)
dbxBinds(vm)
filesystemBinds(vm)
@@ -246,6 +244,8 @@ func (p *plugin) registerHooks() error {
httpClientBinds(vm)
formsBinds(vm)
apisBinds(vm)
mailsBinds(vm)
vm.Set("$app", p.app)
vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir)
+6 -6
View File
@@ -15,7 +15,7 @@ import (
"github.com/pocketbase/pocketbase/tools/migrate"
)
const collectionsCacheKey = "migratecmd_collections"
const collectionsStoreKey = "migratecmd_collections"
// onCollectionChange handles the automigration snapshot generation on
// collection change event (create/update/delete).
@@ -94,7 +94,7 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error {
}
func (p *plugin) updateSingleCachedCollection(new, old *models.Collection) {
cached, _ := p.app.Cache().Get(collectionsCacheKey).(map[string]*models.Collection)
cached, _ := p.app.Store().Get(collectionsStoreKey).(map[string]*models.Collection)
switch {
case new == nil:
@@ -103,7 +103,7 @@ func (p *plugin) updateSingleCachedCollection(new, old *models.Collection) {
cached[new.Id] = new
}
p.app.Cache().Set(collectionsCacheKey, cached)
p.app.Store().Set(collectionsStoreKey, cached)
}
func (p *plugin) refreshCachedCollections() error {
@@ -121,19 +121,19 @@ func (p *plugin) refreshCachedCollections() error {
cached[c.Id] = c
}
p.app.Cache().Set(collectionsCacheKey, cached)
p.app.Store().Set(collectionsStoreKey, cached)
return nil
}
func (p *plugin) getCachedCollections() (map[string]*models.Collection, error) {
if !p.app.Cache().Has(collectionsCacheKey) {
if !p.app.Store().Has(collectionsStoreKey) {
if err := p.refreshCachedCollections(); err != nil {
return nil, err
}
}
result, _ := p.app.Cache().Get(collectionsCacheKey).(map[string]*models.Collection)
result, _ := p.app.Store().Get(collectionsStoreKey).(map[string]*models.Collection)
return result, nil
}
+5 -7
View File
@@ -118,13 +118,11 @@ func (p *plugin) createCommand() *cobra.Command {
`
command := &cobra.Command{
Use: "migrate",
Short: "Executes app DB migration scripts",
Long: cmdDesc,
ValidArgs: []string{"up", "down", "create", "collections"},
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
Use: "migrate",
Short: "Executes app DB migration scripts",
Long: cmdDesc,
ValidArgs: []string{"up", "down", "create", "collections"},
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
cmd := ""
if len(args) > 0 {
Loaded 100 of 459 files, more files were not shown because too many files have changed in this diff. Show more