Compare commits

...
732 Commits
Author SHA1 Message Date
Gani Georgiev c4116e3a7d added jsvm sleep binding 2023-12-29 23:29:00 +02:00
Gani Georgiev 64cee264f0 bumped app version 2023-12-29 22:00:47 +02:00
Gani Georgiev 0ae9f24a81 updated fields query param examples for the auth actions 2023-12-29 21:47:04 +02:00
Gani Georgiev 6d942c7d30 docs fixes commits from develop 2023-12-29 21:25:32 +02:00
Gani Georgiev 9f67c5d563 regenerated jsvm types 2023-12-29 09:58:21 +02:00
mookrs 1ac7330e0b [#4043] fixed typos in godoc comments 2023-12-29 09:56:36 +02:00
Gani Georgiev 461886f64e fixed the monospace font loading in the Admin UI 2023-12-27 10:47:18 +02:00
Gani Georgiev 8d97eb0769 [#4022] fixed multi-line text paste in the Admin UI search bar 2023-12-24 11:13:17 +02:00
Gani Georgiev 1208edec92 regenerated jsvm types 2023-12-17 00:20:04 +02:00
Gani Georgiev 5555e63116 updated dev debug log text message color to be slightly more visible 2023-12-17 00:17:29 +02:00
Gani Georgiev d6569b445c added timestamp to the generated JSVM types file to prevent creating it every time on app startup 2023-12-16 23:20:38 +02:00
Gani Georgiev 0b4f3b2adf combine the logs listing label span 2023-12-16 23:16:07 +02:00
Gani Georgiev 8bd968ed06 split changelog in chunks 2023-12-16 18:22:18 +02:00
Gani Georgiev 5c961f8537 [#3918] added --dev flag, dev log printer and some minor log UI enhacements 2023-12-16 18:15:36 +02:00
Gani Georgiev bf5eba0384 added bool to the view query sql syntax highlighter and autocompletion 2023-12-13 09:07:18 +02:00
Gani Georgiev c213d9313e fixed changelog typo 2023-12-12 19:48:28 +02:00
Gani Georgiev b31cf984a5 [#3930] replaced the default 100ms api tests timeout in favor of new ApiScenario.Timeout field 2023-12-12 19:46:58 +02:00
Gani Georgiev 8671debc35 removed the blank current time entry from the logs chart 2023-12-11 09:49:06 +02:00
Gani Georgiev b0f027d27a updated changelog formatting and temp moved the admin only rule checks to the record_helpers 2023-12-10 21:06:02 +02:00
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
Gani Georgiev b3421861e6 updated jsvm types 2023-08-05 09:51:00 +03:00
Gani Georgiev 872492ad22 updated changelog 2023-08-05 07:26:53 +03:00
Gung JodiandGung Jodi 5c14c7cf5e [#3068] fixed RequestData log deprecation note
Co-authored-by: Gung Jodi <agung.pratama@dana.id>
2023-08-05 07:24:20 +03:00
Gani Georgiev b59f0f418e updated ui/dist 2023-08-03 12:42:00 +03:00
Gani Georgiev 06d3e27e03 [#3054] added core.RealtimeConnectEvent.IdleTimeout field 2023-08-03 12:38:02 +03:00
Gani Georgiev b1093baef7 [#3058] soft-deprecated 'data' prop in favour of 'body' to allow raw strings 2023-08-03 12:32:04 +03:00
Gani Georgiev b3f09ff045 updated changelog and go deps 2023-07-31 22:47:35 +03:00
Gani Georgiev b33ad36f64 renamed variable name 2023-07-31 17:48:26 +03:00
Gani Georgiev 9254ce46eb trigger the jsvm cron ticker only on app serve 2023-07-31 14:18:59 +03:00
Gani Georgiev cc8c855306 updated ui/dist 2023-07-31 13:09:39 +03:00
Gani Georgiev b74994b906 [#3026] use relative path for the oauth2 provider page link 2023-07-31 13:07:30 +03:00
Gani Georgiev f652dc71bb [#3025] manually trigger the OnBeforeServe hook for tests.ApiScenario 2023-07-31 12:27:22 +03:00
Gani Georgiev 6d2677a5e3 fixed cronRemove docs declaration 2023-07-30 18:02:16 +03:00
Gani Georgiev 0c5305c174 fixed incomplete sentence in the changelog 2023-07-30 16:20:58 +03:00
Gani Georgiev 5398576f4f updated changelog formatting 2023-07-30 15:22:10 +03:00
Gani Georgiev 3d1f570b38 updated jsvm types 2023-07-30 14:12:35 +03:00
Gani Georgiev fa057502f1 updated ui/dist deps 2023-07-30 14:10:42 +03:00
Gani Georgiev bb4a5cfe83 updated ui/dist and some lint warnings 2023-07-30 13:40:22 +03:00
Gani Georgiev ac1fd74942 updated tests 2023-07-30 10:02:44 +03:00
Gani Georgiev db660ac780 revert the default max perPage limit to 500 for now 2023-07-29 21:44:31 +03:00
Gani Georgiev cdeb9a94ed added action arg to the before Dao hook to allow skipping the default persist behavior 2023-07-29 19:52:36 +03:00
Gani Georgiev 6da94aef8d updated jsvm panic handling when HooksWatch is set 2023-07-29 16:01:53 +03:00
Gani Georgiev 0a4fdc17a5 enabled tokens binds and removed primitive constructors overwrites 2023-07-29 13:56:31 +03:00
Gani Georgiev 1bbba7a0ae added cron expression UTC timezone note 2023-07-28 22:24:21 +03:00
Gani Georgiev fcc4e305e0 removed unnecessary large timeout (for reordering the queue 0 should be enough) 2023-07-27 16:10:59 +03:00
Gani Georgiev 854796a8dd [#3000] disallowed relations to views from non-view collections 2023-07-27 15:57:20 +03:00
Gani Georgiev e6a41773ca [#2588] added warning message in case the update command is run in a Docker container or NixOS 2023-07-26 13:18:59 +03:00
Gani Georgiev f4a6d8af49 excluded unnecessary types to reduce the size of the generated declarations file 2023-07-26 10:45:06 +03:00
Gani Georgiev 1563855251 [#2992] added migration to reset already inserted null values 2023-07-26 00:40:48 +03:00
Gani Georgiev 1330e2e1e7 [#2992] fixed zero-default value not being used if the field is not explicitly set when manually creating records 2023-07-25 20:37:19 +03:00
Gani Georgiev 34fe55686d wrapped tests.ApiScenario execution in a subtest 2023-07-25 13:37:43 +03:00
Gani Georgiev b0aa387235 removed extra param unescaping as it was fixed in echo 2023-07-25 13:36:57 +03:00
Gani Georgiev c3f7aeb856 register LoadAuthContext as Pre so that the auth context is aavailable other Pre middlewares 2023-07-25 12:45:41 +03:00
Gani Georgiev 54a6ae6710 updated record.PublicExport comment 2023-07-25 06:11:50 +03:00
Gani Georgiev 8dfc90985b added native echo.HandlerFunc support and .staticDirectoryHandler bind 2023-07-24 21:11:55 +03:00
Gani Georgiev 99ea916c14 renamed expand fetchFunc args to optFetchFunc and updated jsvm types 2023-07-24 16:59:13 +03:00
Gani Georgiev 70151a3c19 added bindings 2023-07-24 16:39:11 +03:00
Gani Georgiev 543fb350ec added jsvm .* helpers 2023-07-24 13:59:13 +03:00
Gani Georgiev ea4e3128ca updated jsvm types 2023-07-24 12:45:23 +03:00
Gani Georgiev ae8cbc8f45 added template.Registry.LoadFS method 2023-07-24 12:33:46 +03:00
Gani Georgiev cb156e1903 increased the default sqlite cache_size to 16mb 2023-07-24 10:35:42 +03:00
Gani Georgiev edcb6950e5 watch pb_hooks subdirectories 2023-07-23 23:45:41 +03:00
Gani Georgiev 085fb1601e added jsvm binding 2023-07-23 16:43:38 +03:00
Gani Georgiev 132a8c0aab added template.Registry.LoadString test 2023-07-23 15:48:01 +03:00
Gani Georgiev 4f3ca6fe2b added helper html template rendering utils 2023-07-23 15:37:30 +03:00
Gani Georgiev 13c0572fe1 updated jsvm types 2023-07-22 19:01:20 +03:00
Gani Georgiev fda4b67dbc updated ui/dist 2023-07-22 18:59:45 +03:00
Gani Georgiev aefbccbfea replaced os.IsNotExists 2023-07-22 18:59:33 +03:00
Gani Georgiev d1336da339 make use of skipTotal 2023-07-22 18:50:40 +03:00
Gani Georgiev f453cefc0b updated go.mod and jsvm types 2023-07-21 23:36:37 +03:00
Gani Georgiev b6bc09fee1 updated jsvm types 2023-07-21 23:29:01 +03:00
Gani Georgiev 437843084b added search skipTotal support 2023-07-21 23:24:36 +03:00
Gani Georgiev 1e4c665b53 [#2957] added support for wrapped api errors 2023-07-20 22:01:58 +03:00
Gani Georgiev ac52befb5b changed subscription.Message.Data to []byte and added client.Send(m) helper 2023-07-20 21:25:13 +03:00
Gani Georgiev 50d7df45eb added ?download file serve query param support to force file download 2023-07-20 15:04:26 +03:00
Gani Georgiev 7e0a4e61b4 updated ui/dist 2023-07-20 14:33:45 +03:00
Gani Georgiev 689ad644c1 updated npm deps 2023-07-20 13:16:16 +03:00
Gani Georgiev f660707712 added e.action to the realtime docs preview 2023-07-20 13:10:23 +03:00
Gani Georgiev 06016722d1 removed legacy 404 check and preserved collections sort order within each type group 2023-07-20 13:06:49 +03:00
Gani Georgiev 832d7f360c updated tinymce 2023-07-20 12:00:23 +03:00
Gani Georgiev 939653ecc0 added after hooks error response tests 2023-07-20 11:42:57 +03:00
Gani Georgiev 610a948dcc added Response.Committed checks 2023-07-20 10:40:03 +03:00
Gani Georgiev b2284b5f4b updated OnModel hooks comment for consistency with the site docs 2023-07-19 18:23:39 +03:00
Gani Georgiev d9e1a759a1 make use of the after hook finalizer 2023-07-18 15:31:36 +03:00
Gani Georgiev 624b443f98 removed unnecessary collection queries 2023-07-18 13:41:14 +03:00
Gani Georgiev 71a70bac9d updated jsvm errors handling 2023-07-18 12:36:04 +03:00
Gani Georgiev 0110869c89 soft deprecated apis.RequestData(c) in favor of apis.RequestInfo(c) and updated jsvm bindings 2023-07-17 23:13:39 +03:00
Gani Georgiev 7d4017225c synced with master 2023-07-17 13:17:17 +03:00
Gani Georgiev 94a1cc07d5 [#2930] added extra normalizations to ensure that newly created multiple fields has the correct zero-default for already inserted records 2023-07-17 11:38:19 +03:00
Gani Georgiev 1720c82570 updated comment 2023-07-17 00:08:06 +03:00
Gani Georgiev 81bd1a1732 reset the requestData Admin and AuthRecord fields 2023-07-17 00:05:15 +03:00
Gani Georgiev f421da4b9b use Dao.CanAccessRecord when checking for protected file access 2023-07-17 00:03:09 +03:00
Gani Georgiev 3eaa3ca1b5 fixed .send binding tests 2023-07-16 23:38:49 +03:00
Gani Georgiev 2d1ad16b4f updated cron jsvm bindings and generated types 2023-07-16 23:24:10 +03:00
Gani Georgiev 6179864828 return the http.Server instance to allow manual shutdowns 2023-07-16 23:13:15 +03:00
Gani Georgiev 64d7ab22f3 treat returned false bool from a jsvm hook as hook.stopPropagation 2023-07-14 16:50:35 +03:00
Gani Georgiev 4962dc618b added record.ExpandedOne(rel) and record.ExpandedAll(rel) helpers 2023-07-14 15:21:59 +03:00
Gani Georgiev 8e2246113a synced with master 2023-07-14 12:44:26 +03:00
Gani Georgiev b9993aaa73 updated changelog 2023-07-14 12:16:56 +03:00
Gani Georgiev f77fb0cc1c updated tests with some clarification code comments 2023-07-14 12:13:44 +03:00
Gani Georgiev 460cc35bb6 updated ui/dist 2023-07-14 12:01:48 +03:00
Gani Georgiev f0bcffec8b [#2914] register the eagerRequestDataCache middleware only for the api grroup to avoid conflicts with custom routes 2023-07-14 11:55:29 +03:00
Gani Georgiev 2b465b0646 load a default fetchFunc for dao.ExpandRecord(s) 2023-07-14 08:36:01 +03:00
Gani Georgiev fdccdcebad added option to call Dao.RecordQuery() with the collection id or name 2023-07-13 22:38:55 +03:00
Gani Georgiev a38bd5bedc tests types.d.ts in gitignore 2023-07-12 17:31:26 +03:00
Gani Georgiev 6fe04bd280 returned OnAfterBootstrap error and added more jsvm tests 2023-07-12 17:12:45 +03:00
Gani Georgiev d0a68da7e7 fixed jsvm docs path 2023-07-11 18:19:33 +03:00
Gani Georgiev ede67dbc20 added jsvm bindings and updateing the workflow to generate the jsvm types 2023-07-11 18:09:55 +03:00
Gani Georgiev 3d3fe5c614 updated Dao.CanAccessRecord to return the invalid filter or db error 2023-07-11 11:50:10 +03:00
Gani Georgiev 7bb33d4453 updated Application URL input label for consistency 2023-07-09 16:44:29 +03:00
Gani Georgiev 0ad4dbc65a synced with master 2023-07-08 21:29:21 +03:00
Gani Georgiev c3844250e8 updated go deps 2023-07-08 20:06:34 +03:00
Gani Georgiev c293994d2b added hooksPool flag and updated doc comments 2023-07-08 20:02:03 +03:00
Gani Georgiev a557aa35f5 updated readme and Find*ByFilter godoc comment 2023-07-08 14:01:02 +03:00
Gani Georgiev 736e9673ad updated generated types 2023-07-08 13:59:24 +03:00
Gani Georgiev 13d96e793b (no tests) updated jsvm bindings 2023-07-08 13:51:16 +03:00
Gani Georgiev 5e37c90dde added cron.Total method 2023-07-08 13:51:00 +03:00
Gani Georgiev 7bcd00a87e synced with master 2023-07-06 23:38:37 +03:00
Gani Georgiev ebfbb55f91 allow no space between the index table name and columns list 2023-07-06 23:20:31 +03:00
Gani Georgiev d77479131a [#2868] fixed unique validator detailed error message not being returned when camelCase field name is used 2023-07-06 23:14:18 +03:00
Gani Georgiev 8ef00efe84 allow no space between the index table name and columns list 2023-07-06 15:47:16 +03:00
Gani Georgiev a4101f7670 synced with master 2023-07-03 20:53:09 +03:00
Gani Georgiev 08b4fc20a9 fixed changelog typo 2023-07-03 19:46:40 +03:00
Gani Georgiev 9ec01d74d8 optimized search count queries to use rowid by default 2023-07-03 17:57:23 +03:00
Gani Georgiev 320f990f84 [#2818] fixed text field regex pattern example 2023-06-30 19:46:00 +03:00
Gani Georgiev 7297f55ca4 [#2817] allowed 0 as RelationOptions.MinSelect value 2023-06-30 18:13:56 +03:00
Gani Georgiev 2cb642bbf7 aliased and soft-deprecated NewToken with NewJWT, added encrypt/decrypt goja bindings and other minor doc changes 2023-06-28 22:56:03 +03:00
Gani Georgiev ecdf9c26cd added comments and typedoc group tags to the generated docs 2023-06-28 21:39:57 +03:00
Gani Georgiev a672ab959f merged jsvm migrations and hooks and updated the ambient TS types location 2023-06-27 14:45:04 +03:00
Gani Georgiev 1571ebe4eb use microseconds when inserting the auto generated migration 2023-06-27 00:35:17 +03:00
Gani Georgiev b8bb5e8d72 fixed migrate down not returning the correct migrations order when the stored applied time is in seconds 2023-06-27 00:33:31 +03:00
Gani Georgiev af77554250 enabled baseBinds for the goja migrations 2023-06-26 23:17:37 +03:00
Gani Georgiev 3b68782cfb synced with master 2023-06-26 18:21:49 +03:00
Gani Georgiev 1679c88e6d updated binder test cases 2023-06-26 13:04:15 +03:00
Gani Georgiev 91bbbc4bdb added eager empty string check 2023-06-26 12:53:15 +03:00
Gani Georgiev 4ab9c6f87f updated ui/dist 2023-06-26 12:50:21 +03:00
Gani Georgiev 68157a3a65 added missing array value normalization 2023-06-26 12:47:56 +03:00
Gani Georgiev e6cf4ad2ef updated go deps 2023-06-26 12:44:14 +03:00
Gani Georgiev edd2eaae88 added negative number test 2023-06-26 12:42:53 +03:00
Gani Georgiev 2e8e835a68 updated ui/dist 2023-06-26 12:33:55 +03:00
Gani Georgiev 9cba6ac386 [#2763] fixed multipart/form-data array value bind 2023-06-26 12:30:51 +03:00
Gani Georgiev 8388e36f28 updated readme note 2023-06-26 11:03:54 +03:00
Gani Georgiev 051b3702b0 replaced DynamicList with a more generic (model) helper to allow creating pointer slice of any type 2023-06-25 20:19:12 +03:00
Gani Georgiev 39accdba58 updated go deps 2023-06-23 22:26:05 +03:00
Gani Georgiev 32de0aa40a use direct string comparison in the ApiError message test 2023-06-23 22:23:03 +03:00
Gani Georgiev 9bfcdd086a replaced .* errors with constructors and added apisBinds tests 2023-06-23 22:20:13 +03:00
Gani Georgiev 1d20124467 updated changelog 2023-06-23 14:15:53 +03:00
Gani GeorgievandValentine 435eca6f35 [#2762] added Yandex OAuth2 provider
Co-authored-by: Valentine <xb2w1z@gmail.com>
2023-06-23 14:13:43 +03:00
Gani Georgiev 0a61db6efd add @todo note to the RequestData struct 2023-06-23 13:38:13 +03:00
Gani Georgiev 7ab8405946 removed goja middlewares that don't make much sense in the goja context 2023-06-23 12:54:08 +03:00
Gani Georgiev 6fa3e99be2 use inflector.UcFirst instead of strings.Title 2023-06-22 21:54:39 +03:00
Gani Georgiev 3160fb2d99 added DynamicModel form tag and removed unused helper 2023-06-22 16:32:21 +03:00
Gani Georgiev 1cbf16b3bf ucfirst the DynamicModel field name so that we can use later the same FieldMapper resolver rules 2023-06-22 16:29:58 +03:00
Gani Georgiev dad289b90d bind hooksWatch flag 2023-06-21 21:46:13 +03:00
Gani Georgiev c795ecd21e updated jsvm generated types 2023-06-21 20:40:43 +03:00
Gani Georgiev 21607f0f66 updated cobra.Command constructor and update structConstructor to use goja.Object.Set 2023-06-21 20:36:57 +03:00
Gani Georgiev fc311a8d28 removed the temp len binding as the issue was fixed in goja#521 2023-06-21 13:40:59 +03:00
Gani Georgiev 93606c6647 added DynamicModel and DynamicList goja bindings 2023-06-21 11:21:54 +03:00
Gani Georgiev 1adcfcc03b adde json map Get and Set helpers 2023-06-20 22:57:51 +03:00
Gani Georgiev ed4304dc30 added jsvm typings and docs generation 2023-06-20 08:54:02 +03:00
Gani Georgiev c0a6a21b9e updated code comments and added some notes 2023-06-19 21:45:45 +03:00
Gani Georgiev a7bb599cd0 Merge branch 'master' into develop 2023-06-16 14:48:52 +03:00
Gani Georgiev bd94940eef updated changelog 2023-06-16 14:47:34 +03:00
Sven-Kristjan Kompus caf343ef9c [#2726] removed unnecessary Dao().TotalAdmins() call 2023-06-16 14:43:05 +03:00
Gani Georgiev 28ba4655c1 added note about the disk space when creating backups 2023-06-16 12:24:28 +03:00
Gani Georgiev bd95a5b74c [#2693] removed the implicit autosnapshot migration creation as it is not clear to the users when it happens 2023-06-14 13:21:37 +03:00
Gani Georgiev 745b230097 updated jsvm mapper and updated godoc formatting 2023-06-14 13:14:30 +03:00
Gani Georgiev ec303a60ed [#2271] added dao.CanAccessRecord() helper 2023-06-14 13:13:21 +03:00
Gani Georgiev e99b0627d6 synced with master 2023-06-10 23:43:09 +03:00
Gani Georgiev d1bcaa65b1 updated changelog 2023-06-10 23:34:32 +03:00
Gani Georgiev 2d60ad170e updated ui/dist 2023-06-10 23:32:41 +03:00
Simon Loir 0ba963a5d7 [#2681] fixed collection index column sort normalization 2023-06-10 23:30:32 +03:00
Gani Georgiev b77b6d1a18 updated ui/dist 2023-06-09 13:45:42 +03:00
Gani Georgiev 779b23d919 synced with master 2023-06-09 13:33:38 +03:00
Gani Georgiev 87a6f1bebb updated deps and removed unnecessary normalizeSort() call 2023-06-09 13:32:01 +03:00
Gani Georgiev aa5b5bb83e added default initial -created sort 2023-06-09 13:07:34 +03:00
Gani Georgiev af193d440d [#2675] fixed implicit relation display fields serialization 2023-06-09 12:57:17 +03:00
Gani Georgiev 7f06816008 normalized active sort on collection schema change 2023-06-09 12:56:07 +03:00
Gani Georgiev a5b27cce5c fixed apis.NewUnauthorizedError test 2023-06-08 18:16:00 +03:00
Gani Georgiev ebd6891471 updated broken tests 2023-06-08 18:14:01 +03:00
Gani Georgiev 3cf3e04866 restructered some of the internals and added basic js app hooks support 2023-06-08 17:59:08 +03:00
Gani Georgiev ff5508cb79 synced with master 2023-06-02 19:53:53 +03:00
Gani Georgiev e4a90f6605 updated npm deps and generated ui/dist 2023-06-02 19:41:13 +03:00
Gani Georgiev f07f7a1e35 synced with master 2023-06-02 19:38:36 +03:00
Gani Georgiev acbce42bff updated changelog 2023-06-02 19:33:20 +03:00
Gani Georgiev b33bebd4cd bumped app version 2023-06-02 19:32:26 +03:00
Gani Georgiev 881b625177 updated changelog 2023-06-02 19:23:43 +03:00
Gani Georgiev 4c2dcac61a added dao.WithoutHooks() helper 2023-06-01 15:42:38 +03:00
Gani Georgiev dcb00a3917 updated changelog 2023-05-31 21:51:01 +03:00
Gani Georgiev ddca49ba16 [#2309] added query by filter record helpers 2023-05-31 11:49:16 +03:00
Gani Georgiev 0fb92720f8 Merge branch 'master' into develop 2023-05-30 21:22:39 +03:00
Gani Georgiev 3122223a71 [#2602] added common int types support when scanning types.DateTime 2023-05-30 20:43:20 +03:00
Gani Georgiev 7de346b532 fixed realtime delete event to be called after the record was deleted from the db 2023-05-29 22:28:07 +03:00
Gani Georgiev 729f9f142e check after hook errors 2023-05-29 21:50:07 +03:00
Gani Georgiev 45b73e3dfb Merge branch 'master' into develop 2023-05-29 17:01:16 +03:00
Gani Georgiev dbbc1e25ca replaced multiple error wraps with plain %v for older go 1.18 and go 1.19 compatibility 2023-05-29 17:01:04 +03:00
Gani Georgiev d3711b0503 added new core.ServeEvent fields 2023-05-29 16:57:50 +03:00
Gani Georgiev 9d8df8d05d added option to remove single registered hook handler 2023-05-29 14:51:03 +03:00
Gani Georgiev 97f29e4305 synced with master 2023-05-28 23:57:19 +03:00
Gani Georgiev 732044f795 keep both original file and fallback errors 2023-05-28 22:00:48 +03:00
Gani Georgiev 9a1354ae62 [#2589] added .exe fallback to the selfupdate cmd and replaced path with filepath 2023-05-28 21:57:12 +03:00
Gani Georgiev fcfcaa0628 refresh the cached logged admin and auth record 2023-05-28 17:36:56 +03:00
Gani Georgiev d5314b028b synced with master 2023-05-27 14:12:34 +03:00
Gani Georgiev e3876c0e13 updated nocgo busy_timeout pragma 2023-05-27 09:37:21 +03:00
Gani Georgiev b1307c9041 registered custom cgo sqlite driver 2023-05-27 09:24:18 +03:00
Gani Georgiev dcdf43f0fc return conn.Exec error 2023-05-27 09:07:38 +03:00
Gani Georgiev f6a616b7e8 [#2570] fixed default PRAGMAs not being applied for new connections 2023-05-27 09:04:01 +03:00
Gani Georgiev 3be5875ea9 Merge branch 'master' into develop 2023-05-25 21:00:45 +03:00
Gani Georgiev 833a84b3d7 [#2570] use ON for consistency with the SQLite examples and updated collection delete test to check for _exterAuths references 2023-05-25 20:26:03 +03:00
Gani Georgiev 94680c41f7 synced with master 2023-05-24 23:31:31 +03:00
Gani Georgiev c72c951b24 [#2567] fixed schema fields sort not working on Safari/Gnome Web 2023-05-24 23:19:06 +03:00
Gani GeorgievandValentine af71b63f23 [#2533] added VK OAuth2 provider
Co-authored-by: Valentine <xb2w1z@gmail.com>
2023-05-24 15:41:58 +03:00
Gani Georgiev e40cf46b33 synced with master 2023-05-24 11:07:29 +03:00
Gani Georgiev e9969ed6d1 updated changelog 2023-05-24 09:34:23 +03:00
Gani Georgiev 511259ed10 fixed missing view id field error message typo 2023-05-24 09:25:46 +03:00
Gani Georgiev 6b16b7856b [#2551] auto register the initial generated snapshot migration to prevent incorrectly reapplying the snapshot on container restart 2023-05-24 09:23:10 +03:00
Gani Georgiev 5b330ab5b4 updated compareVersions tests 2023-05-23 23:15:56 +03:00
Gani Georgiev 7dcfa65146 updated ui/dist 2023-05-23 22:47:04 +03:00
Gani GeorgievandPedro Costa a6bb1bf096 [#2534] added Instagram OAuth2 provider
Co-authored-by: Pedro Costa <550684+pnmcosta@users.noreply.github.com>
2023-05-23 22:37:44 +03:00
Gani Georgiev 728427cecf Merge branch 'master' into develop 2023-05-23 21:56:23 +03:00
Gani Georgiev 231ddc9791 [#2541] removed file field dataTransfer.effectAllowed checks 2023-05-23 21:09:24 +03:00
Gani Georgiev 5bf2f692d8 fixed formatting 2023-05-23 21:08:22 +03:00
Gani Georgiev ce28a9af78 [#2548] use fileath.Clean on the fs.WalkDirFunc argument to ensure that the same normalizations are applied 2023-05-23 18:34:24 +03:00
Gani Georgiev db20e38cda updated backups test to ensure that the backups dir is not part of the generated zip 2023-05-23 16:47:58 +03:00
Gani Georgiev 651b439096 synced with master 2023-05-23 11:36:55 +03:00
Gani Georgiev 286046e15a improved update cmd version check 2023-05-23 11:35:54 +03:00
Gani Georgiev 7a41ff0127 added version check todo 2023-05-23 10:46:54 +03:00
Gani Georgiev 4440b5f817 updated changelog 2023-05-23 10:32:31 +03:00
Gani Georgiev d7745ba702 [#2541] fixed file field drag and drop not working in Firefox and Safari 2023-05-23 10:23:41 +03:00
Gani Georgiev 007ef1e152 [#2540] fixed missing CommonHelper references on Downloaad as JSON btn 2023-05-23 07:55:21 +03:00
Gani Georgiev a291cb5ca7 [#2535] avoid mutating the cached request data on OAuth2 user create 2023-05-22 23:59:36 +03:00
Gani Georgiev 86049ed048 updated changelog 2023-05-22 08:02:03 +03:00
Gani Georgiev 6457fc794f updated ui/dist 2023-05-22 07:59:56 +03:00
Gani Georgiev 0eecbd8289 updated app.RestoreBackup() godoc 2023-05-22 07:58:44 +03:00
David Schissler 6521decfa8 [#2526] fixed typo in BackupCreatePanel.svelte 2023-05-22 07:50:42 +03:00
Kunal Singh c370d84074 [#2527] removed unnecessary slice check 2023-05-22 07:48:22 +03:00
Gani Georgiev 4f5aa6ffda updated changelog and ui/dist 2023-05-21 21:06:20 +03:00
Frangu Madalin 8921712821 [#2523] fixed realtime dart api preview example 2023-05-21 21:00:24 +03:00
Gani Georgiev 1c63ae1324 [#2519] replace os.Rename with manually moving the dir children 2023-05-21 20:46:47 +03:00
Gani Georgiev cbaca91581 ignore the v prefix when comparing the tag and the current version 2023-05-21 13:00:12 +03:00
Gani Georgiev 5551f8f5aa eager update app settings and added isServe check for the auto backups 2023-05-21 11:47:05 +03:00
Gani Georgiev a56a04ed0e updated deps 2023-05-21 10:55:33 +03:00
Gani Georgiev 47bb0bc2d8 updated the verification api preview example 2023-05-20 23:53:04 +03:00
Gani Georgiev f49e90bd0b added fields query param to the api docs preview 2023-05-20 06:03:30 +03:00
Gani Georgiev 3820f3d7d7 updated page sidebar width styles 2023-05-19 13:48:58 +03:00
Gani Georgiev faaf19d3bb [#2510] added missing .length prop to the select searchable check 2023-05-19 13:46:30 +03:00
Gani Georgiev 6d0303deaf added meta.isNew to the json OAuth2 auth response 2023-05-18 00:19:54 +03:00
Gani Georgiev d36a044dc7 updated npm deps 2023-05-17 23:59:54 +03:00
Gani Georgiev 1cc67ef7e3 added sorting for the multiple relations form field 2023-05-17 23:54:06 +03:00
Gani Georgiev 1e2d1045b8 added title attrbite to the collection sidebar items and increased the default width 2023-05-17 23:33:43 +03:00
Gani Georgiev df5a291d20 use the filenames instead of indexes on delete 2023-05-17 23:17:45 +03:00
Gani Georgiev 04e0ad9b21 [#2445] added support for multiple files sort in the Admin UI 2023-05-17 22:41:42 +03:00
Gani Georgiev 24ab233376 added experimental update command 2023-05-17 21:14:12 +03:00
Gani Georgiev a42ab6a205 removed the legacy temp upgrade command 2023-05-17 18:39:15 +03:00
Gani Georgiev 472671fee1 fixed comment typo and updated default fallback displayable props 2023-05-16 09:35:33 +03:00
Gani Georgiev 6bde84131c [#2466] added accept file field attr 2023-05-13 22:23:19 +03:00
Gani Georgiev e8b4a7eb26 added backup apis and tests 2023-05-13 22:12:42 +03:00
Gani Georgiev 3b0f60fe15 added basic cron util 2023-05-09 22:36:40 +03:00
Gani Georgiev d3314e1e23 (untested!) added temp backup api scaffoldings before introducing autobackups and rotations 2023-05-08 21:52:40 +03:00
Gani Georgiev 60eee96034 synced with master 2023-05-05 08:49:21 +03:00
Gani Georgiev c6d5992442 updated changelog and ui/dist 2023-05-05 05:52:48 +03:00
Gani Georgiev a7f3805f87 [#2423] insert default settings params with the init migration 2023-05-05 05:22:00 +03:00
Gani Georgiev 92f21e3355 trigger the OnTerminate hook and use a buffered chan instead of wg 2023-05-04 15:12:28 +03:00
Gani Georgiev 4c57968d89 synced with master 2023-05-03 14:52:10 +03:00
Gani Georgiev b84ebabf4a [#2410] Fixed editor field fullscreen z-index 2023-05-03 14:37:19 +03:00
Gani Georgiev 90abe1612e added helper archive package to create and extract zips 2023-05-02 14:51:15 +03:00
Gani Georgiev dfabfa779e synced with master 2023-05-01 22:07:17 +03:00
Gani Georgiev 28c9f02cb4 updated js sdk and resolved isNew record field conflict 2023-05-01 13:00:12 +03:00
Gani Georgiev 67e1974228 updated js sdk 2023-04-28 16:22:38 +03:00
Gani Georgiev 0dcd332962 temporary change the base backend env url 2023-04-28 15:55:26 +03:00
Gani Georgiev f70b457507 updated ui/dist 2023-04-28 15:47:48 +03:00
Gani Georgiev c8fd831115 fixed changelog typos 2023-04-27 20:53:49 +03:00
Gani Georgiev beca0a044e changed X-Forwarded-For parsing to use the first non-empty leftmost-ish ip as it is more close to the 'real ip' 2023-04-27 20:52:08 +03:00
Gani Georgiev 9fa56b020c [#2372] use Fly-Client-IP header if available for the 'real' user ip 2023-04-27 20:29:59 +03:00
Gani Georgiev 429929dd0c added explicit readonly and disabled props to MultipleValueInput 2023-04-27 08:30:04 +03:00
Gani Georgiev ecf7d657c0 updated npm deps and added border to the expanded schema field 2023-04-26 22:15:41 +03:00
Gani Georgiev 4296148c87 updated ui/dist 2023-04-26 18:55:57 +03:00
Gani Georgiev bfb38ab51e added select readonly prop and updated the disabled schema field state 2023-04-26 18:35:34 +03:00
Gani Georgiev 1967dcfeba updated schema field options dropdown spacing 2023-04-26 18:04:08 +03:00
Gani Georgiev 5c95e9b109 [#2318] updated schema fields ui 2023-04-26 14:12:47 +03:00
Gani Georgiev 39c3a95a08 Merge branch 'master' into develop 2023-04-25 19:18:42 +03:00
Gani Georgiev 74306c8092 updated go deps 2023-04-25 18:31:27 +03:00
Gani Georgiev a5b3cc0f34 use relative oauth2 path redirect to support subpath proxy deployments 2023-04-25 18:29:36 +03:00
Gani Georgiev 4944884683 updated changelog 2023-04-25 18:11:04 +03:00
Gani Georgiev c0a7d0f6c0 added ?fields query parameter support to limit the returned api fields 2023-04-25 17:58:51 +03:00
Gani Georgiev 841a4b6913 synced with master 2023-04-25 12:40:59 +03:00
Gani Georgiev 0478f84867 updated /api/oauth2-redirect error messages 2023-04-25 11:52:56 +03:00
Gani Georgiev 0b5e189563 added OAuth2 redirect fallback message to notify the user to go back to the app 2023-04-25 08:36:02 +03:00
Gani Georgiev 7ce21cc4d9 Merge branch 'master' into develop 2023-04-24 16:04:45 +03:00
Gani Georgiev e61d48fe7b [#2349] fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source 2023-04-24 15:43:23 +03:00
Gani Georgiev 7acd90498c synced with master 2023-04-24 13:25:47 +03:00
Gani Georgiev cae3315e46 [#2349] fixed view query SELECT DISTINCT parsing 2023-04-24 12:54:57 +03:00
Gani Georgiev f2a011d63f [#2334] removed first char lowercase normalization to comply with the common backend regex 2023-04-21 19:03:59 +03:00
Gani Georgiev b31c2ceffa synced with master 2023-04-21 11:53:20 +03:00
Gani Georgiev a2a170ad82 updated go deps 2023-04-21 10:30:01 +03:00
Gani Georgiev 92dcee7250 skip Cache-Control header for the Admin UI root path 2023-04-21 10:28:28 +03:00
Gani Georgiev bd2521b14b added cache-control header for the admin ui assets 2023-04-21 01:19:57 +03:00
Gani Georgiev 0c63e0e219 added editor loading placeholder 2023-04-21 00:42:41 +03:00
Gani Georgiev d407ca2163 updated ui/dist 2023-04-20 23:59:59 +03:00
Gani Georgiev 5c57be8469 removed tinymce autoresize transition 2023-04-20 23:55:50 +03:00
Gani Georgiev d8f9b52011 updated go deps 2023-04-20 23:45:51 +03:00
Gani Georgiev faa0a9f7dc added admin console command tests 2023-04-20 23:39:48 +03:00
Gani Georgiev 4d94673839 Merge branch 'master' into develop 2023-04-20 21:18:49 +03:00
Gani Georgiev 4b0e614c68 updated changelog 2023-04-20 17:42:38 +03:00
Gani Georgiev 5c95a43042 move relations load to the end of the execution queue t 2023-04-20 17:38:04 +03:00
Gani Georgiev 025c9313e1 added admin console command 2023-04-20 17:20:56 +03:00
Gani Georgiev 0b023b2c02 Merge branch 'master' into develop 2023-04-20 16:24:46 +03:00
Gani Georgiev 8240addfab fixed rtl editor menu item typo 2023-04-20 15:36:42 +03:00
Gani Georgiev e1cfe7fc4a [#2327] enabled rtl for the editor field 2023-04-20 15:33:02 +03:00
Gani Georgiev 01f4765c09 added Command+S quick save alias and fixed relations draft restore 2023-04-20 14:38:24 +03:00
Gani Georgiev 818857dea2 [#2325] trigger the related record realtime events on custom record model change 2023-04-20 10:44:20 +03:00
Gani Georgiev 3358d8476b added apis.Serve helper 2023-04-20 05:06:22 +03:00
Gani Georgiev 060ed8013e synced with master 2023-04-19 14:09:06 +03:00
Gani Georgiev fdf4f6d3bd fixed ctrl+s not getting triggered in the editor field 2023-04-19 10:33:24 +03:00
Gani Georgiev d69e72deaf updated changelog 2023-04-18 07:09:07 +03:00
Gani Georgiev 0420a00c18 updated ui/dist 2023-04-18 06:32:01 +03:00
Gani Georgiev b39d607c8a updated changelog and package.json 2023-04-18 06:30:05 +03:00
Gani Georgiev abea28a1a9 fixed minor typos 2023-04-17 22:22:41 +03:00
Gani Georgiev a7d5a0640c allowed specifying non-context auth model for the file token endpoint 2023-04-17 22:05:09 +03:00
Gani Georgiev c937c06688 updated ui/dist 2023-04-17 20:59:04 +03:00
Gani Georgiev d11cec2226 updated cascade delete tooltip 2023-04-17 16:33:26 +03:00
Gani Georgiev c179b4c473 updated oauth2 docs and added create api rule tooltip 2023-04-17 16:29:56 +03:00
Gani Georgiev 00b04db5cf added ctrl+s record form shortcut and updated user external auths panel with the new provider logos 2023-04-17 14:35:49 +03:00
Gani Georgiev 1b8776926e updated protected files visualization 2023-04-17 13:28:41 +03:00
Gani Georgiev 5eb54c7a3d store unsaved record changes in local storage 2023-04-17 12:43:48 +03:00
Gani Georgiev 6127350e91 added eagerRequestDataCache middleware 2023-04-15 14:44:07 +03:00
Gani Georgiev 177230a765 renamed private to protected 2023-04-15 13:27:42 +03:00
Gani Georgiev 8cb0d980ad updated dart record view example and the relation cascade delete label 2023-04-15 11:27:07 +03:00
Gani Georgiev d44c2bb1a9 removed implicit autofocus as it is causing keyboard navigation issues 2023-04-15 11:23:59 +03:00
Gani Georgiev 3423433d01 updated ui/dist 2023-04-15 00:50:10 +03:00
Gani Georgiev 646b6a925b limit the number of displayed thumbs in the records listing 2023-04-15 00:47:49 +03:00
Gani Georgiev 5ddf9cd443 don't skip temp indexes migration if the indexes column is already created 2023-04-15 00:44:19 +03:00
Gani Georgiev 0351f3a1ad updated overlay panel autofocus 2023-04-14 15:28:24 +03:00
Gani Georgiev 53c735d00b added simple loose wildcard search term support in the Admin UI 2023-04-14 14:45:50 +03:00
Gani Georgiev 25769e971a added import.meta.env.BASE_URL prefix for the auth provider logos 2023-04-14 13:32:59 +03:00
Gani Georgiev aba6279feb Merge branch 'master' into develop 2023-04-14 12:58:03 +03:00
Gani Georgiev ac4a961a10 added unique error test for the record update api 2023-04-13 23:04:24 +03:00
Gani Georgiev 8317ae2e6b [#2287] fixed unique field detailed error not being returned on DrySubmit failure 2023-04-13 22:37:10 +03:00
Gani Georgiev b347c8d80c [#2246] added drop files support for the file field 2023-04-13 16:46:50 +03:00
Gani Georgiev af5f808144 updated OAuth2 providers ui 2023-04-13 15:38:12 +03:00
Gani Georgiev a77b62e5bd added extra table name checks in the overwritten dao hooks in case of duplicated ids 2023-04-12 16:46:08 +03:00
Gani Georgiev c77467a6a2 synced with master 2023-04-12 16:31:36 +03:00
Gani Georgiev 35e433f26b updated changelog and ui/dist 2023-04-12 16:23:53 +03:00
Gani Georgiev 59f23d3d23 [#2277] added check for nil dao hooks 2023-04-12 16:09:35 +03:00
Gani Georgiev 6a69a035a7 synced with master 2023-04-12 14:27:17 +03:00
Gani Georgiev b8373b23a4 updated changelog 2023-04-12 10:31:11 +03:00
Gani Georgiev 25c1db40ac [#2272] fixed panic on list.ExistInSliceWithRegex cache 2023-04-12 10:27:22 +03:00
Gani Georgiev 7b48e9d1c1 updated go deps 2023-04-10 23:00:36 +03:00
Gani Georgiev b537085bca updated test scenario name 2023-04-10 22:58:07 +03:00
Gani Georgiev 3e5b021dd8 fixed oauth2SubscriptionRedirect test 2023-04-10 22:51:59 +03:00
Gani Georgiev 151dbafc86 updated ui/dist 2023-04-10 22:33:13 +03:00
Gani Georgiev dc72d5adee [#55] added OAuth2 subscription redirect handler 2023-04-10 22:27:00 +03:00
Gani Georgiev c826514eca synced with master 2023-04-07 11:39:45 +03:00
Gani Georgiev b7407edcae updated go.mod 2023-04-06 20:22:50 +03:00
Gani Georgiev 1420d717e3 [#2231] revert the aws-sdk-go-v2 change 2023-04-06 20:17:22 +03:00
Gani Georgiev f5f8c35a17 [#2221] fixed Admin UI Logs meta field visualization in Firefox 2023-04-05 19:43:41 +03:00
Gani Georgiev 733d7dacdb [#215] updated the admin ui to allow displaying private files 2023-04-05 13:23:22 +03:00
Gani Georgiev cd45854792 [#215] added Admin UI Private file option toggle 2023-04-04 21:38:03 +03:00
Gani Georgiev ba7000125b [#215] enabled Settings.AdminFileToken validations and added more tests 2023-04-04 20:47:03 +03:00
Gani Georgiev 64c3e3b3c5 [#215] added server-side handlers for serving private files 2023-04-04 20:33:35 +03:00
Gani Georgiev 9f76ad234c [#2205] fixed Record.WithUnknownData() typo 2023-04-03 22:01:59 +03:00
Gani Georgiev 8ca439e5f7 updated ui/dist 2023-04-03 21:11:04 +03:00
Gani Georgiev f31a3b133c revert part of the old COALESCE handling to support missing joined relation fields comparison with empty string 2023-04-03 20:27:52 +03:00
Gani Georgiev 850fe353da updated changelog 2023-04-01 13:00:28 +03:00
Gani Georgiev 16a6b32a76 updated go deps 2023-04-01 12:55:42 +03:00
Gani Georgiev a1487c3235 updated the record file handling for the before update hook too 2023-04-01 12:51:00 +03:00
Gani Georgiev 5255515e26 updated ui/dist 2023-04-01 12:38:04 +03:00
Gani Georgiev 0b90ce43c3 Merge branch 'develop' 2023-04-01 12:36:10 +03:00
Gani Georgiev 899b8217e0 fix realtime events firing before files upload completion 2023-04-01 11:47:01 +03:00
Gani Georgiev 69bf9779d9 added Dao.Clone() helper 2023-04-01 11:45:08 +03:00
Gani Georgiev 48d6803d17 check only the existence of the thumb and add ContentType metadata when creating the thumb 2023-03-31 23:06:22 +03:00
Gani Georgiev 216efb95a8 updated Dao.Save godoc 2023-03-30 21:19:14 +03:00
Gani GeorgievandYuxiang Gao 29a264e132 [#1346] upgraded to aws-sdk-v2
Co-authored-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2023-03-30 16:10:13 +03:00
Gani Georgiev 976a9e2f27 updated tinymce 2023-03-30 14:42:28 +03:00
Gani Georgiev 005e77151d removed unused tinymce themes 2023-03-30 14:35:43 +03:00
Gani Georgiev f12467f2b3 updated list api docs 2023-03-30 12:25:33 +03:00
Gani Georgiev fe03dd0789 use backtick in index column preset 2023-03-30 12:17:56 +03:00
Gani Georgiev ff1f99436a prevent closing the datefield options on calendar select and updated collections upsert save handler 2023-03-28 09:06:16 +03:00
Gani Georgiev 3ea02c945d updated changelog and formatting 2023-03-28 07:59:37 +03:00
Gani Georgiev 4c903684d8 added linux cgo target 2023-03-27 21:39:46 +03:00
Gani Georgiev d0239f25ed updated changelog and ui/dist 2023-03-27 19:44:09 +03:00
Gani Georgiev 05adb8c018 update ui deps 2023-03-27 16:17:37 +03:00
Gani Georgiev c901c9ab7d updated go deps 2023-03-27 16:16:09 +03:00
Gani Georgiev c9fba9972a cleanup old remaining temp views 2023-03-27 16:07:47 +03:00
Gani Georgiev 2c40811fac added required field marker 2023-03-27 16:07:20 +03:00
Gani Georgiev b81112f82e minor ui adjustments 2023-03-26 22:49:33 +03:00
Gani Georgiev 0acf8198a4 revert record upsert changes on upload failure 2023-03-26 22:49:18 +03:00
Gani Georgiev 5ece360808 autoselect new field names 2023-03-26 20:32:41 +03:00
Gani Georgiev 3a5d3d521f added ProviderName and ProviderClient fields to core.RecordAuthWithOAuth2Event 2023-03-26 19:32:23 +03:00
Gani Georgiev f024de3cc4 updated ui/dist 2023-03-26 19:19:44 +03:00
Gani Georgiev a4abe9e2cb removed unused AuthProviderConfig.Meta field 2023-03-25 23:34:00 +02:00
Gani Georgiev 5678339af0 added migrate history-sync command 2023-03-25 21:48:19 +02:00
Gani Georgiev e5a22b8bd8 added a flag indicating OAuth2 auth record creation 2023-03-25 15:18:28 +02:00
Gani Georgiev be0ee7f66c fixed generateAppleClientSecret call 2023-03-24 22:48:32 +02:00
Gani Georgiev 78a9f7429c updated ui/dist 2023-03-24 22:30:57 +02:00
Gani Georgiev d8b4daf47f updated rule field styles 2023-03-23 22:59:02 +02:00
Gani Georgiev 7a2360d785 [#2029] enable any id aliased column in a view query to be detected as relation field 2023-03-23 19:51:06 +02:00
Gani Georgiev 67ecebe935 [#1939] removed redundant COALESCE normalizations 2023-03-23 19:30:35 +02:00
Gani Georgiev 5cbdbf6c10 updated ui/dist 2023-03-23 16:15:19 +02:00
Gani Georgiev 0710214701 added new field indicator 2023-03-23 10:42:27 +02:00
Gani Georgiev 9736a45e80 renamed daos.GetTableColumns and daos.GetTableInfo for consistency 2023-03-22 17:15:17 +02:00
Gani Georgiev 923fc26a31 changed types.JsonArray to support generics 2023-03-22 17:12:44 +02:00
Gani Georgiev a79f3a7c56 removed test js file 2023-03-22 16:53:34 +02:00
Gani Georgiev 8430944650 deprecated SchemaField.Unique 2023-03-22 15:47:34 +02:00
Gani Georgiev 9b54fd3516 added debug log for already committed response error 2023-03-22 15:42:35 +02:00
Gani Georgiev 226352f884 replaced indirect expand field.Unique with unique index constraint check 2023-03-21 21:38:28 +02:00
Gani Georgiev 2a682a10b2 preload tinymce 2023-03-21 18:52:13 +02:00
Gani Georgiev b67549209a [#2118] added option to explicitly set the record id from the Admin UI 2023-03-21 17:56:31 +02:00
Gani Georgiev 1126a0e16f updated changelog and ui/dist 2023-03-21 15:55:42 +02:00
Gani Georgiev 17472cb40a minor internal indexes handling adjustments and test 2023-03-21 15:31:20 +02:00
Gani Georgiev 981de64c7f added Index.Build helper method 2023-03-21 14:26:44 +02:00
Gani Georgiev 1b45e23c81 removed unnecessary helper method and updated index parser regex 2023-03-19 22:15:18 +02:00
Gani Georgiev 29621303df updated indexes column migration to load unique fields and custom indexes 2023-03-19 22:13:52 +02:00
Gani Georgiev a0ec5707d1 (no tests) collection indexes scaffoldings 2023-03-19 16:18:33 +02:00
Gani Georgiev 695c20a969 decreased bcrypt round hash for admins to 12 for consistency with the auth records 2023-03-19 16:10:11 +02:00
Gani Georgiev 95bb2eb871 update automigrate templates to check collection indexes 2023-03-19 16:02:29 +02:00
Gani Georgiev 971916c20d removed unnecessary mail layou css class 2023-03-19 10:16:23 +02:00
Gani Georgiev 44f5172db7 added create index sql parser 2023-03-19 10:15:26 +02:00
Gani Georgiev 5fd103481c updated ui/dist 2023-03-19 10:14:44 +02:00
Gani Georgiev 82abd7e0b0 minor collection ui adjustments 2023-03-19 08:17:36 +02:00
Gani Georgiev 361cfb56b7 [#1904] simplified default mail template styles 2023-03-16 22:35:03 +02:00
Gani Georgiev 4d16d0e16e new schema and indexes ui 2023-03-16 19:21:16 +02:00
Gani Georgiev 254e691e92 [#2072] registered RemoveTrailingSlash middleware only for the /api/* routes 2023-03-15 18:09:49 +02:00
Gani Georgiev e735d9d21b synced with master 2023-03-14 02:04:34 +02:00
Gani Georgiev 31b24ecb86 removed eager unique collection name check to allow lazy validation during bulk import 2023-03-14 01:31:30 +02:00
Gani Georgiev 3eb3d0bbdf lowered auth record bcrypt round factor to 12 2023-03-13 15:52:51 +02:00
Gani Georgiev f180af7af7 synced with master 2023-03-12 18:09:16 +02:00
Gani Georgiev cce9116fa7 updated ui/dist 2023-03-12 17:31:24 +02:00
Gani Georgiev 7cfbe49b32 updated changelog 2023-03-12 17:22:50 +02:00
Gani Georgiev b564be06f4 updated RecordsPicker to show proper view colllection relations 2023-03-12 17:12:35 +02:00
Gani Georgiev f5493dd608 updated ui/dist 2023-03-12 17:00:57 +02:00
Gani Georgiev 49227f5436 fixed views relation picker load action and updated the record preview 2023-03-12 16:44:24 +02:00
Gani Georgiev 94d2f296b2 [#2044] fixed view collections import 2023-03-12 16:43:27 +02:00
Gani Georgiev d046811df7 optimized single relation lookups 2023-03-07 23:28:35 +02:00
Gani Georgiev 4768e07c0b added multiple->single value conversion warning 2023-03-06 23:24:50 +02:00
Gani Georgiev 0547202382 use nonconcurrent db when deleting log requests 2023-03-06 23:24:31 +02:00
Gani Georgiev 684f91f7e5 added indirect view update by source collection field change 2023-03-06 20:10:18 +02:00
Gani Georgiev bce4094134 updated changelog 2023-03-06 15:32:46 +02:00
Gani Georgiev 6a60bc1df3 meged apple-oauth2 2023-03-06 15:30:35 +02:00
Gani Georgiev 19b7a7a74f updated ui/dist 2023-03-06 15:29:03 +02:00
Gani Georgiev 8de0e61cf4 synced with master 2023-03-06 15:27:17 +02:00
Gani Georgiev 5344ec83fa normalized values on maxSelect change 2023-03-06 15:20:07 +02:00
Gani Georgiev af34eb544d [#1989] fixed RecordInfo multiple files preview 2023-03-06 13:33:15 +02:00
Gani Georgiev 65aa114103 added google OAuth2 verified_email check 2023-03-05 19:19:11 +02:00
Gani Georgiev 8728161288 sync with latest changes 2023-03-05 16:16:07 +02:00
Gani Georgiev 2420b2804a synced with master 2023-03-05 16:12:51 +02:00
Gani Georgiev 42e288c71a [#1976] added HEAD requests support for the file download action 2023-03-05 15:39:18 +02:00
Gani Georgiev 2791628d96 minor updates to the rule fields ui 2023-03-05 15:18:51 +02:00
Gani Georgiev 01f0af7af6 added errors slide transition 2023-03-04 13:06:24 +02:00
Gani Georgiev e8d61e7b45 simplified rules ui 2023-03-04 13:05:55 +02:00
Gani Georgiev a5ac83c7b0 use jwt.ParseECPrivateKeyFromPEM instead of the custom one 2023-03-02 21:31:27 +02:00
Gani Georgiev a67c14c368 added support for @request.headers.* 2023-03-02 18:56:18 +02:00
Gani Georgiev 19faa0d8e7 updated calendar styles 2023-03-02 16:10:56 +02:00
Gani Georgiev 56a77fd5ff updated migrations comment 2023-03-02 15:17:29 +02:00
Gani Georgiev 07727dbde6 [#1956] normalized _requests.method to UPPERCASE 2023-03-02 15:15:00 +02:00
Gani Georgiev a3d26a73c3 removed unnecessary struct pointer 2023-03-02 14:07:46 +02:00
Gani Georgiev b328827705 added generate-client-secret api test 2023-03-01 23:45:54 +02:00
Gani Georgiev f5e5fae773 added apple oauth2 integration 2023-03-01 23:29:51 +02:00
Gani Georgiev 41f01bab0d fixed collections import message typo 2023-02-28 16:50:25 +02:00
Gani Georgiev dc96a12dc0 updated ui/dist 2023-02-26 11:00:52 +02:00
Gani Georgiev 0bb58eb52c minor UI fixes 2023-02-26 10:47:00 +02:00
Gani Georgiev cf6d325add added model query autocancellation test 2023-02-24 22:07:25 +02:00
Gani Georgiev 578e1c9bc1 [#223] updated the internal redirects to allow easier subpath deployment when behind a reverse proxy 2023-02-24 18:49:46 +02:00
Gani Georgiev 4778fc7a46 added min select relation field option 2023-02-24 16:34:02 +02:00
Gani Georgiev f1a6a82bd3 prevent collectionId relation field change 2023-02-24 14:12:27 +02:00
Gani Georgiev 21b152b58c fixed formatting and typos 2023-02-23 21:51:42 +02:00
Gani Georgiev aa4e405f92 replaced authentik with generic oidc provider 2023-02-23 21:07:00 +02:00
Gani Georgiev e529fe7e2a added --queryTimeout flag 2023-02-23 18:59:23 +02:00
Gani Georgiev 6ab2fa9489 updated default query timeout to 1m 2023-02-23 12:04:16 +02:00
Gani Georgiev 010a396b0e updated dao fail/retry handling 2023-02-22 22:20:19 +02:00
Gani Georgiev 65a148b741 added UploadedFiles to the record create/update events 2023-02-22 22:09:13 +02:00
Gani Georgiev 503c65a767 [#1896] run files upload after record save 2023-02-22 22:06:59 +02:00
Gani Georgiev eec3261d67 added cancelKey to the RecordsList component 2023-02-22 18:02:08 +02:00
Gani Georgiev 0db6c783cd minor ui improvements 2023-02-21 22:24:49 +02:00
Gani Georgiev 4fdc8feafc updated daos.HasTable to check also for views 2023-02-21 20:03:27 +02:00
Gani Georgiev 41c3cc8a90 added select auto fail/retry 2023-02-21 16:54:08 +02:00
Gani Georgiev 0afb09b3bd updated view ui 2023-02-21 16:32:58 +02:00
Gani Georgiev 1075292321 fixed godoc formatting 2023-02-20 13:31:03 +02:00
Gani Georgiev b184ef6c3a fixed daos.SaveView test 2023-02-18 20:31:41 +02:00
Gani Georgiev 5fd7f61656 temporary change the github action's go version to 1.19 2023-02-18 20:06:22 +02:00
Gani Georgiev 851711e231 updated go deps 2023-02-18 20:02:47 +02:00
Gani Georgiev a07f67002f added view collection type 2023-02-18 19:33:42 +02:00
Gani Georgiev 0052e2ab2a synced with master 2023-02-12 14:11:12 +02:00
Gani Georgiev fa65a20202 updated changelog 2023-02-06 19:57:43 +02:00
Gani Georgiev 668698feb2 [#1797] enabled goja/process module for accessing the os.Environ 2023-02-06 19:53:21 +02:00
Gani Georgiev f475967a4a updated default tokenizer separators 2023-02-06 16:30:47 +02:00
Gani Georgiev 23dfa9c634 added generic tokenizer helper 2023-02-05 20:59:17 +02:00
Gani Georgiev 1b21e86be6 synced with master 2023-02-02 15:35:03 +02:00
Gani Georgiev 7d38971850 updated changelog example 2023-02-01 22:19:36 +02:00
Gani Georgiev 15102138c3 updated changelog 2023-02-01 22:10:00 +02:00
Gani Georgiev 2378bc72c5 [#1728] normalized mailer.Message recipient fields 2023-02-01 22:07:46 +02:00
Gani Georgiev 69b80123de Merge branch 'master' into develop 2023-01-30 17:26:40 +02:00
Gani Georgiev 250642a8f9 allowed overwriting the default file serve headers if an explicit response header is set 2023-01-30 12:54:51 +02:00
713 changed files with 63783 additions and 57463 deletions
+10 -3
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.0'
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.
@@ -29,6 +29,13 @@ jobs:
- name: Build Admin dashboard UI
run: npm --prefix=./ui ci && npm --prefix=./ui run build
# Temporary disable as the types can have random generated identifiers making it non-deterministic.
#
# # Similar to the above, the jsvm types are pregenerated locally
# # but its here to ensure that it wasn't forgotten to be executed.
# - name: Generate jsvm types
# run: go run ./plugins/jsvm/internal/types/types.go
# The prebuilt golangci-lint doesn't support go 1.18+ yet
# https://github.com/golangci/golangci-lint/issues/2649
# - name: Run linter
@@ -42,6 +49,6 @@ jobs:
with:
distribution: goreleaser
version: latest
args: release --rm-dist
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+18 -2
View File
@@ -7,7 +7,21 @@ before:
- go mod tidy
builds:
- main: ./examples/base
# used only for tests
- id: build_cgo
main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
- id: build_noncgo
main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
@@ -33,7 +47,9 @@ release:
draft: true
archives:
- format: zip
- id: archive_noncgo
builds: [build_noncgo]
format: zip
files:
- LICENSE*
- CHANGELOG*
+687 -1057
View File
File diff suppressed because it is too large Load Diff
+1384
View File
File diff suppressed because it is too large Load Diff
+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.
+3
View File
@@ -4,6 +4,9 @@ lint:
test:
go test ./... -v --cover
jstypes:
go run ./plugins/jsvm/internal/types/types.go
test-report:
go test ./... -v --cover -coverprofile=coverage.out
go tool cover -html=coverage.out
+14 -14
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).
@@ -146,14 +146,14 @@ You could help continuing its development by:
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us)
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
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.
+67 -63
View File
@@ -1,7 +1,6 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
@@ -33,18 +32,28 @@ type adminApi struct {
app core.App
}
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin, finalizers ...func(token string) error) error {
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
for _, f := range finalizers {
if err := f(token); err != nil {
return err
}
}
event := new(core.AdminAuthEvent)
event.HttpContext = c
event.Admin = admin
event.Token = token
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(200, map[string]any{
"token": e.Token,
"admin": e.Admin,
@@ -62,17 +71,11 @@ func (api *adminApi) authRefresh(c echo.Context) error {
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
return api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.app.OnAdminAfterAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
})
})
if handlerErr == nil {
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *adminApi) authWithPassword(c echo.Context) error {
@@ -95,17 +98,13 @@ func (api *adminApi) authWithPassword(c echo.Context) error {
return NewBadRequestError("Failed to authenticate.", err)
}
return api.authResponse(e.HttpContext, e.Admin)
return api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -129,30 +128,29 @@ 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() {
log.Println(err)
if err := next(e.Admin); err != nil {
api.app.Logger().Error("Failed to send admin password reset request.", "error", err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
// eagerly write 204 response and skip submit errors
// as a measure against admins enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
return submitErr
}
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
@@ -173,17 +171,17 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -208,6 +206,10 @@ func (api *adminApi) list(c echo.Context) error {
event.Result = result
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
@@ -228,6 +230,10 @@ func (api *adminApi) view(c echo.Context) error {
event.Admin = admin
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
}
@@ -256,17 +262,17 @@ func (api *adminApi) create(c echo.Context) error {
return NewBadRequestError("Failed to create admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return api.app.OnAdminAfterCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -302,17 +308,17 @@ func (api *adminApi) update(c echo.Context) error {
return NewBadRequestError("Failed to update admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return api.app.OnAdminAfterUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -331,19 +337,17 @@ func (api *adminApi) delete(c echo.Context) error {
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
return api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
return NewBadRequestError("Failed to delete admin.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnAdminAfterDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
+140
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -8,6 +9,7 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
@@ -89,6 +91,26 @@ func TestAdminAuthWithPassword(t *testing.T) {
"OnAdminAuthRequest": 1,
},
},
{
Name: "OnAdminAfterAuthWithPasswordRequest error response",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -224,6 +246,29 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
"OnAdminAfterConfirmPasswordResetRequest": 1,
},
},
{
Name: "OnAdminAfterConfirmPasswordResetRequest error response",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"password":"1234567891",
"passwordConfirm":"1234567891"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminBeforeConfirmPasswordResetRequest": 1,
"OnAdminAfterConfirmPasswordResetRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -278,6 +323,25 @@ func TestAdminRefresh(t *testing.T) {
"OnAdminAfterAuthRefreshRequest": 1,
},
},
{
Name: "OnAdminAfterAuthRefreshRequest error response",
Method: http.MethodPost,
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthRefreshRequest": 1,
"OnAdminAfterAuthRefreshRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -510,6 +574,27 @@ func TestAdminDelete(t *testing.T) {
"OnAdminBeforeDeleteRequest": 1,
},
},
{
Name: "OnAdminAfterDeleteRequest error response",
Method: http.MethodDelete,
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterDeleteRequest().Add(func(e *core.AdminDeleteEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
"OnAdminBeforeDeleteRequest": 1,
"OnAdminAfterDeleteRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -637,6 +722,33 @@ func TestAdminCreate(t *testing.T) {
"OnAdminAfterCreateRequest": 1,
},
},
{
Name: "OnAdminAfterCreateRequest error response",
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567890",
"passwordConfirm":"1234567890",
"avatar":3
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterCreateRequest().Add(func(e *core.AdminCreateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnAdminBeforeCreateRequest": 1,
"OnAdminAfterCreateRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -729,6 +841,7 @@ func TestAdminUpdate(t *testing.T) {
},
},
{
Name: "authorized as admin + valid data",
Method: http.MethodPatch,
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
@@ -759,6 +872,33 @@ func TestAdminUpdate(t *testing.T) {
"OnAdminAfterUpdateRequest": 1,
},
},
{
Name: "OnAdminAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567891",
"passwordConfirm":"1234567891",
"avatar":5
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterUpdateRequest().Add(func(e *core.AdminUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminBeforeUpdateRequest": 1,
"OnAdminAfterUpdateRequest": 1,
},
},
}
for _, scenario := range scenarios {
+50 -26
View File
@@ -66,43 +66,67 @@ func NewUnauthorizedError(message string, data any) *ApiError {
// NewApiError creates and returns new normalized `ApiError` instance.
func NewApiError(status int, message string, data any) *ApiError {
message = inflector.Sentenize(message)
formattedData := map[string]any{}
if v, ok := data.(validation.Errors); ok {
formattedData = resolveValidationErrors(v)
}
return &ApiError{
rawData: data,
Data: formattedData,
Data: safeErrorsData(data),
Code: status,
Message: strings.TrimSpace(message),
Message: strings.TrimSpace(inflector.Sentenize(message)),
}
}
func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
func safeErrorsData(data any) map[string]any {
switch v := data.(type) {
case validation.Errors:
return resolveSafeErrorsData[error](v)
case map[string]validation.Error:
return resolveSafeErrorsData[validation.Error](v)
case map[string]error:
return resolveSafeErrorsData[error](v)
case map[string]any:
return resolveSafeErrorsData[any](v)
default:
return map[string]any{} // not nil to ensure that is json serialized as object
}
}
func resolveSafeErrorsData[T any](data map[string]T) map[string]any {
result := map[string]any{}
// extract from each validation error its error code and message.
for name, err := range validationErrors {
// check for nested errors
if nestedErrs, ok := err.(validation.Errors); ok {
result[name] = resolveValidationErrors(nestedErrs)
for name, err := range data {
if isNestedError(err) {
result[name] = safeErrorsData(err)
continue
}
errCode := "validation_invalid_value" // default
if errObj, ok := err.(validation.ErrorObject); ok {
errCode = errObj.Code()
}
result[name] = map[string]string{
"code": errCode,
"message": inflector.Sentenize(err.Error()),
}
result[name] = resolveSafeErrorItem(err)
}
return result
}
func isNestedError(err any) bool {
switch err.(type) {
case validation.Errors, map[string]validation.Error, map[string]error, map[string]any:
return true
}
return false
}
// resolveSafeErrorItem extracts from each validation error its
// public safe error code and message.
func resolveSafeErrorItem(err any) map[string]string {
// default public safe error values
code := "validation_invalid_value"
msg := "Invalid value."
// only validation errors are public safe
if obj, ok := err.(validation.Error); ok {
code = obj.Code()
msg = inflector.Sentenize(obj.Error())
}
return map[string]string{
"code": code,
"message": msg,
}
}
+12 -12
View File
@@ -37,10 +37,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
300,
"message_test",
validation.Errors{
"err1": errors.New("test error"),
"err1": errors.New("test error"), // should be normalized
"err2": validation.ErrRequired,
"err3": validation.Errors{
"sub1": errors.New("test error"),
"sub1": errors.New("test error"), // should be normalized
"sub2": validation.ErrRequired,
"sub3": validation.Errors{
"sub11": validation.ErrRequired,
@@ -50,10 +50,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
)
result, _ := json.Marshal(e)
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Invalid value."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
if string(result) != expected {
t.Errorf("Expected %v, got %v", expected, string(result))
t.Errorf("Expected \n%v, \ngot \n%v", expected, string(result))
}
if e.Error() != "Message_test." {
@@ -73,7 +73,7 @@ func TestNewNotFoundError(t *testing.T) {
}{
{"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
{"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -81,7 +81,7 @@ func TestNewNotFoundError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
@@ -94,7 +94,7 @@ func TestNewBadRequestError(t *testing.T) {
}{
{"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
{"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -102,7 +102,7 @@ func TestNewBadRequestError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
@@ -115,7 +115,7 @@ func TestNewForbiddenError(t *testing.T) {
}{
{"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
{"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -123,7 +123,7 @@ func TestNewForbiddenError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
@@ -136,7 +136,7 @@ func TestNewUnauthorizedError(t *testing.T) {
}{
{"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
{"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -144,7 +144,7 @@ func TestNewUnauthorizedError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
+214
View File
@@ -0,0 +1,214 @@
package apis
import (
"context"
"net/http"
"path/filepath"
"time"
"github.com/labstack/echo/v5"
"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"
)
// bindBackupApi registers the file api endpoints and the corresponding handlers.
//
// @todo add hooks once the app hooks api restructuring is finalized
func bindBackupApi(app core.App, rg *echo.Group) {
api := backupApi{app: app}
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())
}
type backupApi struct {
app core.App
}
func (api *backupApi) list(c echo.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
backups, err := fsys.List("")
if err != nil {
return NewBadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
}
result := make([]models.BackupFileInfo, len(backups))
for i, obj := range backups {
modified, _ := types.ParseDateTime(obj.ModTime)
result[i] = models.BackupFileInfo{
Key: obj.Key,
Size: obj.Size,
Modified: modified,
}
}
return c.JSON(http.StatusOK, result)
}
func (api *backupApi) create(c echo.Context) error {
if api.app.Store().Has(core.StoreKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
}
form := forms.NewBackupCreate(api.app)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
return form.Submit(func(next forms.InterceptorNextFunc[string]) forms.InterceptorNextFunc[string] {
return func(name string) error {
if err := next(name); err != nil {
return NewBadRequestError("Failed to create 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) 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")
_, err := api.app.Dao().FindAdminByToken(
fileToken,
api.app.Settings().AdminFileToken.Secret,
)
if err != nil {
return NewForbiddenError("Insufficient permissions to access the resource.", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
key := c.PathParam("key")
br, err := fsys.GetFile(key)
if err != nil {
return NewBadRequestError("Failed to retrieve backup item. Raw error: \n"+err.Error(), nil)
}
defer br.Close()
return fsys.Serve(
c.Response(),
c.Request(),
key,
filepath.Base(key), // without the path prefix (if any)
)
}
func (api *backupApi) restore(c echo.Context) error {
if api.app.Store().Has(core.StoreKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
}
key := c.PathParam("key")
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(existsCtx)
if exists, err := fsys.Exists(key); !exists {
return NewBadRequestError("Missing or invalid backup file.", err)
}
go func() {
// wait max 15 minutes to fetch the backup
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// give some optimistic time to write the response
time.Sleep(1 * time.Second)
if err := api.app.RestoreBackup(ctx, key); err != nil {
api.app.Logger().Error("Failed to restore backup", "key", key, "error", err.Error())
}
}()
return c.NoContent(http.StatusNoContent)
}
func (api *backupApi) delete(c echo.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
key := c.PathParam("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)
}
if err := fsys.Delete(key); err != nil {
return NewBadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
}
+758
View File
@@ -0,0 +1,758 @@
package apis_test
import (
"archive/zip"
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"gocloud.dev/blob"
)
func TestBackupsList(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/backups",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (empty list)",
Method: http.MethodGet,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`[]`,
},
},
{
Name: "authorized as admin",
Method: http.MethodGet,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"test1.zip"`,
`"test2.zip"`,
`"test3.zip"`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsCreate(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/backups",
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",
RequestHeaders: map[string]string{
"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 (pending backup)",
Method: http.MethodPost,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.Store().Set(core.StoreKeyActiveBackup, "")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (autogenerated name)",
Method: http.MethodPost,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 backup file, got %d", total)
}
expected := "pb_backup_"
if !strings.HasPrefix(files[0].Key, expected) {
t.Fatalf("Expected backup file with prefix %q, got %q", expected, files[0].Key)
}
},
ExpectedStatus: 204,
},
{
Name: "authorized as admin (invalid name)",
Method: http.MethodPost,
Url: "/api/backups",
Body: strings.NewReader(`{"name":"!test.zip"}`),
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":{"code":"validation_match_invalid"`,
},
},
{
Name: "authorized as admin (valid name)",
Method: http.MethodPost,
Url: "/api/backups",
Body: strings.NewReader(`{"name":"test.zip"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 backup file, got %d", total)
}
expected := "test.zip"
if files[0].Key != expected {
t.Fatalf("Expected backup file %q, got %q", expected, files[0].Key)
}
},
ExpectedStatus: 204,
},
}
for _, scenario := range scenarios {
scenario.Test(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{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/backups/test1.zip",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with record auth header",
Method: http.MethodGet,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with admin auth header",
Method: http.MethodGet,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with empty or invalid token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid record auth token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid record file token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid admin auth token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with expired admin file token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid admin file token but missing backup name",
Method: http.MethodGet,
Url: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid admin file token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`storage/`,
`data.db`,
`logs.db`,
},
},
{
Name: "with valid admin file token and backup name with escaped char",
Method: http.MethodGet,
Url: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`storage/`,
`data.db`,
`logs.db`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsDelete(t *testing.T) {
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
expected := 4
if total := len(files); total != expected {
t.Fatalf("Expected %d backup(s), got %d", expected, total)
}
}
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (missing file)",
Method: http.MethodDelete,
Url: "/api/backups/missing.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (existing file with matching active backup)",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
// mock active backup with the same name to delete
app.Store().Set(core.StoreKeyActiveBackup, "test1.zip")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (existing file and no matching active backup)",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
// mock active backup with different name
app.Store().Set(core.StoreKeyActiveBackup, "new.zip")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 3 {
t.Fatalf("Expected %d backup files, got %d", 3, total)
}
deletedFile := "test1.zip"
for _, f := range files {
if f.Key == deletedFile {
t.Fatalf("Expected backup %q to be deleted", deletedFile)
}
}
},
ExpectedStatus: 204,
},
{
Name: "authorized as admin (backup with escaped character)",
Method: http.MethodDelete,
Url: "/api/backups/%40test4.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 3 {
t.Fatalf("Expected %d backup files, got %d", 3, total)
}
deletedFile := "@test4.zip"
for _, f := range files {
if f.Key == deletedFile {
t.Fatalf("Expected backup %q to be deleted", deletedFile)
}
}
},
ExpectedStatus: 204,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsRestore(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/backups/test1.zip/restore",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/backups/test1.zip/restore",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (missing file)",
Method: http.MethodPost,
Url: "/api/backups/missing.zip/restore",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (active backup process)",
Method: http.MethodPost,
Url: "/api/backups/test1.zip/restore",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
app.Store().Set(core.StoreKeyActiveBackup, "")
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// -------------------------------------------------------------------
func createTestBackups(app core.App) error {
ctx := context.Background()
if err := app.CreateBackup(ctx, "test1.zip"); err != nil {
return err
}
if err := app.CreateBackup(ctx, "test2.zip"); err != nil {
return err
}
if err := app.CreateBackup(ctx, "test3.zip"); err != nil {
return err
}
if err := app.CreateBackup(ctx, "@test4.zip"); err != nil {
return err
}
return nil
}
func getBackupFiles(app core.App) ([]*blob.ListObject, error) {
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return nil, err
}
defer fsys.Close()
return fsys.List("")
}
func ensureNoBackups(t *testing.T, app *tests.TestApp) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 0 {
t.Fatalf("Expected 0 backup files, got %d", total)
}
}
+81 -54
View File
@@ -2,18 +2,21 @@
package apis
import (
"database/sql"
"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"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/ui"
"github.com/spf13/cast"
)
@@ -24,51 +27,62 @@ 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: fieldsQueryParam,
}
// configure a custom router
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
return echo.NewRouter(echo.RouterConfig{
UnescapePathParamValues: true,
AllowOverwritingRoute: true,
})
})
// default middlewares
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
Skipper: func(c echo.Context) bool {
// ignore Admin UI route(s)
return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath)
// enable by default only for the API routes
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
},
}))
e.Pre(LoadAuthContext(app))
e.Use(middleware.Recover())
e.Use(middleware.Secure())
e.Use(LoadAuthContext(app))
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) {
if c.Response().Committed {
return
if err == nil {
return // no error
}
var apiErr *ApiError
switch v := err.(type) {
case *echo.HTTPError:
if v.Internal != nil && app.IsDebug() {
log.Println(v.Internal)
}
if errors.As(err, &apiErr) {
// already an api error...
} else if v := new(echo.HTTPError); errors.As(err, &v) {
msg := fmt.Sprintf("%v", v.Message)
apiErr = NewApiError(v.Code, msg, v)
case *ApiError:
if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData())
} else {
if errors.Is(err, sql.ErrNoRows) {
apiErr = NewNotFoundError("", err)
} else {
apiErr = NewBadRequestError("", err)
}
apiErr = v
default:
if err != nil && app.IsDebug() {
log.Println(err)
}
apiErr = NewBadRequestError("", err)
}
logRequest(app, c, apiErr)
if c.Response().Committed {
return // already commited
}
event := new(core.ApiErrorEvent)
@@ -77,6 +91,10 @@ func InitApi(app core.App) (*echo.Echo, error) {
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
// @see https://github.com/labstack/echo/issues/608
if e.HttpContext.Request().Method == http.MethodHead {
return e.HttpContext.NoContent(apiErr.Code)
@@ -85,19 +103,20 @@ func InitApi(app core.App) (*echo.Echo, error) {
return e.HttpContext.JSON(apiErr.Code, apiErr)
})
// truly rare case; eg. client already disconnected
if hookErr != nil && app.IsDebug() {
log.Println(hookErr)
if hookErr == nil {
if err := app.OnAfterApiError().Trigger(event); err != nil {
app.Logger().Debug("OnAfterApiError failure", slog.String("error", hookErr.Error()))
}
} else {
app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error()))
}
app.OnAfterApiError().Trigger(event)
}
// admin ui routes
bindStaticAdminUI(app, e)
// default routes
api := e.Group("/api")
api := e.Group("/api", eagerRequestInfoCache(app))
bindSettingsApi(app, api)
bindAdminApi(app, api)
bindCollectionApi(app, api)
@@ -107,16 +126,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
bindRealtimeApi(app, api)
bindLogsApi(app, api)
bindHealthApi(app, api)
// trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes
serveEvent := &core.ServeEvent{
App: app,
Router: e,
}
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
return nil, err
}
bindBackupApi(app, api)
// catch all any route
api.Any("/*", func(c echo.Context) error {
@@ -163,7 +173,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
e.GET(
strings.TrimRight(trailedAdminPath, "/"),
func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath)
return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
},
)
@@ -173,21 +183,36 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
trailedAdminPath+"*",
echo.StaticDirectoryHandler(ui.DistDirFS, false),
installerRedirect(app),
uiCacheControl(),
middleware.Gzip(),
)
return nil
}
const totalAdminsCacheKey = "totalAdmins"
func uiCacheControl() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// add default Cache-Control header for all Admin UI resources
// (ignoring the root admin path)
if c.Request().URL.Path != trailedAdminPath {
c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
func updateTotalAdminsCache(app core.App) error {
return next(c)
}
}
}
const hasAdminsCacheKey = "@hasAdmins"
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
}
@@ -195,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 {
@@ -211,25 +237,26 @@ 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, trailedAdminPath+"?installer#")
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/")
if hasAdmins && hasInstallerParam {
// clear the installer param
return c.Redirect(http.StatusTemporaryRedirect, "?")
}
return next(c)
+263
View File
@@ -1,13 +1,17 @@
package apis_test
import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
"github.com/spf13/cast"
)
func Test404(t *testing.T) {
@@ -136,3 +140,262 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
scenario.Test(t)
}
}
func TestRemoveTrailingSlashMiddleware(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "non /api/* route (exact match)",
Method: http.MethodGet,
Url: "/custom",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "non /api/* route (with trailing slash)",
Method: http.MethodGet,
Url: "/custom/",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "/api/* route (exact match)",
Method: http.MethodGet,
Url: "/api/custom",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "/api/* route (with trailing slash)",
Method: http.MethodGet,
Url: "/api/custom/",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestEagerRequestInfoCache(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "custom non-api group route",
Method: "POST",
Url: "/custom",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: "POST",
Path: "/custom",
Handler: func(c echo.Context) error {
data := &struct {
Name string `json:"name"`
}{}
if err := c.Bind(data); err != nil {
return err
}
// since the unknown method is not eager cache support
// it should fail reading the json body twice
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "" {
t.Fatalf("Expected empty request data body, got, %v", r.Data)
}
return c.NoContent(200)
},
})
},
ExpectedStatus: 200,
},
{
Name: "api group route with unsupported eager cache request method",
Method: "GET",
Url: "/api/admins",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// it is not important whether the route handler return an error since
// we just need to ensure that the eagerRequestInfoCache was registered
next(c)
// ensure that the body was read at least once
data := &struct {
Name string `json:"name"`
}{}
c.Bind(data)
// since the unknown method is not eager cache support
// it should fail reading the json body twice
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "" {
t.Fatalf("Expected empty request data body, got, %v", r.Data)
}
return nil
}
})
},
ExpectedStatus: 200,
},
{
Name: "api group route with supported eager cache request method",
Method: "POST",
Url: "/api/admins",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// it is not important whether the route handler return an error since
// we just need to ensure that the eagerRequestInfoCache was registered
next(c)
// ensure that the body was read at least once
data := &struct {
Name string `json:"name"`
}{}
c.Bind(data)
// try to read the body again
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "test123" {
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
}
return nil
}
})
},
ExpectedStatus: 200,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestErrorHandler(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "apis.ApiError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return apis.NewApiError(418, "test", nil)
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
},
{
Name: "wrapped apis.ApiError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123: %w", apis.NewApiError(418, "test", nil))
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
NotExpectedContent: []string{"example", "123"},
},
{
Name: "echo.HTTPError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return echo.NewHTTPError(418, "test")
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
},
{
Name: "wrapped echo.HTTPError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123: %w", echo.NewHTTPError(418, "test"))
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
NotExpectedContent: []string{"example", "123"},
},
{
Name: "wrapped sql.ErrNoRows",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123: %w", sql.ErrNoRows)
})
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
NotExpectedContent: []string{"example", "123"},
},
{
Name: "custom error",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
NotExpectedContent: []string{"example", "123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+41 -42
View File
@@ -1,7 +1,6 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
@@ -49,6 +48,10 @@ func (api *collectionApi) list(c echo.Context) error {
event.Result = result
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
@@ -64,6 +67,10 @@ func (api *collectionApi) view(c echo.Context) error {
event.Collection = collection
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
}
@@ -83,7 +90,7 @@ func (api *collectionApi) create(c echo.Context) error {
event.Collection = collection
// create the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
@@ -92,18 +99,16 @@ func (api *collectionApi) create(c echo.Context) error {
return NewBadRequestError("Failed to create the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
return api.app.OnCollectionAfterCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
})
}
})
if submitErr == nil {
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *collectionApi) update(c echo.Context) error {
@@ -124,7 +129,7 @@ func (api *collectionApi) update(c echo.Context) error {
event.Collection = collection
// update the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
@@ -133,18 +138,16 @@ func (api *collectionApi) update(c echo.Context) error {
return NewBadRequestError("Failed to update the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
return api.app.OnCollectionAfterUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
})
}
})
if submitErr == nil {
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *collectionApi) delete(c echo.Context) error {
@@ -157,21 +160,19 @@ func (api *collectionApi) delete(c echo.Context) error {
event.HttpContext = c
event.Collection = collection
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
return api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnCollectionAfterDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *collectionApi) bulkImport(c echo.Context) error {
@@ -187,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
event.Collections = form.Collections
// import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
event.Collections = imports
@@ -196,16 +197,14 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
return NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnCollectionsAfterImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
+385 -78
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"os"
"path/filepath"
@@ -9,8 +10,8 @@ import (
"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/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -45,7 +46,7 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":8`,
`"totalItems":11`,
`"items":[{`,
`"id":"_pb_users_auth_"`,
`"id":"v851q4r790rhknl"`,
@@ -55,6 +56,7 @@ func TestCollectionsList(t *testing.T) {
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"9n89pl5vkct6330"`,
`"id":"ib3m2700k5hlsjz"`,
`"type":"auth"`,
`"type":"base"`,
},
@@ -73,10 +75,10 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
`"totalItems":8`,
`"totalItems":11`,
`"items":[{`,
`"id":"v851q4r790rhknl"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"v9gwnfh02gjq1q0"`,
`"id":"9n89pl5vkct6330"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -231,7 +233,7 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodDelete,
Url: "/api/collections/demo1",
Url: "/api/collections/demo5",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
@@ -243,14 +245,14 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
Name: "authorized as admin + using the collection id",
Method: http.MethodDelete,
Url: "/api/collections/wsmn24bux7wo113",
Url: "/api/collections/9n89pl5vkct6330",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
@@ -262,8 +264,8 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
@@ -292,6 +294,42 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
},
},
{
Name: "authorized as admin + deleting a view",
Method: http.MethodDelete,
Url: "/api/collections/view2",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
},
{
Name: "OnCollectionAfterDeleteRequest error response",
Method: http.MethodDelete,
Url: "/api/collections/view2",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionAfterDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -362,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{
@@ -386,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,
@@ -520,6 +558,155 @@ func TestCollectionCreate(t *testing.T) {
`"options":{"minPasswordLength":{"code":"validation_required"`,
},
},
{
Name: "OnCollectionAfterCreateRequest error response",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionAfterCreateRequest().Add(func(e *core.CollectionCreateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
},
// view
// -----------------------------------------------------------
{
Name: "trying to create view collection with invalid options",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options":{"query": "invalid"}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"options":{"query":{"code":"validation_invalid_view_query`,
},
},
{
Name: "creating view collection",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options": {
"query": "select 1 as id from _admins"
}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"view"`,
`"schema":[]`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
},
// indexes
// -----------------------------------------------------------
{
Name: "creating base collection with invalid indexes",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"test"}
],
"indexes": [
"create index idx_test1 on new (test)",
"create index idx_test2 on new (missing)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"indexes":{"1":{"code":"`,
},
ExpectedEvents: map[string]int{
"OnCollectionBeforeCreateRequest": 1,
"OnModelBeforeCreate": 1,
},
},
{
Name: "creating base collection with valid indexes (+ random table name)",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"test"}
],
"indexes": [
"create index idx_test1 on new (test)",
"create index idx_test2 on anything (id, test)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"base"`,
`"indexes":[`,
`idx_test1`,
`idx_test2`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
}
expected := []string{"idx_test1", "idx_test2"}
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
}
}
},
},
}
for _, scenario := range scenarios {
@@ -577,6 +764,28 @@ func TestCollectionUpdate(t *testing.T) {
"OnModelBeforeUpdate": 1,
},
},
{
Name: "OnCollectionAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionAfterUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnCollectionAfterUpdateRequest": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
{
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPatch,
@@ -614,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'.")
@@ -660,7 +869,7 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "updating base collection with reserved auth fields",
Method: http.MethodPatch,
Url: "/api/collections/demo1",
Url: "/api/collections/demo4",
Body: strings.NewReader(`{
"schema":[
{"type":"text","name":"email"},
@@ -681,7 +890,7 @@ func TestCollectionUpdate(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"demo1"`,
`"name":"demo4"`,
`"type":"base"`,
`"schema":[{`,
`"email"`,
@@ -750,57 +959,89 @@ func TestCollectionUpdate(t *testing.T) {
},
},
// rel field change displayFields propagation
// view
// -----------------------------------------------------------
{
Name: "renaming a display field should also update the referenced displayFields value",
Name: "trying to update view collection with invalid options",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Url: "/api/collections/view1",
Body: strings.NewReader(`{
"schema":[
{
"id": "w5z2x0nq",
"type": "text",
"name": "title_change"
}
]
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options":{"query": "invalid"}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedStatus: 400,
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)
}
`"data":{`,
`"options":{"query":{"code":"validation_invalid_view_query`,
},
},
{
Name: "deleting a display field should also update the referenced displayFields value",
Name: "updating view collection",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Url: "/api/collections/view2",
Body: strings.NewReader(`{
"schema":[
{
"type": "text",
"name": "new_field"
}
"name":"view2_update",
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"options": {
"query": "select 2 as id, created, updated, email from _admins"
}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"view2_update"`,
`"type":"view"`,
`"schema":[{`,
`"name":"email"`,
},
NotExpectedContent: []string{
// base model fields are not part of the schema
`"name":"id"`,
`"name":"created"`,
`"name":"updated"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
},
// indexes
// -----------------------------------------------------------
{
Name: "updating base collection with invalid indexes",
Method: http.MethodPatch,
Url: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique idx_test1 on demo1 (text)",
"create index idx_test2 on demo2 (id, title)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"indexes":{"0":{"code":"`,
},
},
{
Name: "updating base collection with valid indexes (+ random table name)",
Method: http.MethodPatch,
Url: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique index idx_test1 on demo2 (title)",
"create index idx_test2 on anything (active)"
]
}`),
RequestHeaders: map[string]string{
@@ -808,25 +1049,28 @@ func TestCollectionUpdate(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new_field"`,
`"name":"demo2"`,
`"indexes":[`,
"CREATE UNIQUE INDEX `idx_test1`",
"CREATE INDEX `idx_test2`",
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
indexes, err := app.Dao().TableIndexes("new")
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)
expected := []string{"idx_test1", "idx_test2"}
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
}
}
},
},
@@ -837,7 +1081,9 @@ func TestCollectionUpdate(t *testing.T) {
}
}
func TestCollectionImport(t *testing.T) {
func TestCollectionsImport(t *testing.T) {
totalCollections := 11
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
@@ -869,12 +1115,12 @@ func TestCollectionImport(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)
}
expected := 8
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -895,14 +1141,14 @@ func TestCollectionImport(t *testing.T) {
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 6,
"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)
}
expected := 8
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -939,12 +1185,12 @@ func TestCollectionImport(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)
}
expected := 8
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
@@ -974,6 +1220,9 @@ func TestCollectionImport(t *testing.T) {
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
},
{
@@ -992,15 +1241,21 @@ func TestCollectionImport(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)
}
expected := 11
expected := totalCollections + 3
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
indexes, err := app.Dao().TableIndexes("import2")
if err != nil || indexes["idx_test"] == "" {
t.Fatalf("Missing index %s (%v)", "idx_test", err)
}
},
},
{
@@ -1084,14 +1339,14 @@ func TestCollectionImport(t *testing.T) {
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 6,
"OnModelAfterDelete": 6,
"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)
@@ -1102,6 +1357,58 @@ func TestCollectionImport(t *testing.T) {
}
},
},
{
Name: "authorized as admin + successful collections save",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
},
{
"name": "auth_without_schema",
"type": "auth"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnCollectionsAfterImportRequest": 1,
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
},
},
}
for _, scenario := range scenarios {
+188 -17
View File
@@ -1,11 +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"}
@@ -13,14 +28,62 @@ 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)
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
}
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 {
event := new(core.FileTokenEvent)
event.HttpContext = c
if admin, _ := c.Get(ContextAdminKey).(*models.Admin); admin != nil {
event.Model = admin
event.Token, _ = tokens.NewAdminFileToken(api.app, admin)
} else if record, _ := c.Get(ContextAuthRecordKey).(*models.Record); record != nil {
event.Model = record
event.Token, _ = tokens.NewRecordFileToken(api.app, record)
}
return api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
if e.Model == nil || e.Token == "" {
return NewBadRequestError("Failed to generate file token.", nil)
}
return api.app.OnFileAfterTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, map[string]string{
"token": e.Token,
})
})
})
}
func (api *fileApi) download(c echo.Context) error {
@@ -45,15 +108,53 @@ func (api *fileApi) download(c echo.Context) error {
if fileField == nil {
return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
fs, err := api.app.NewFilesystem()
options, ok := fileField.Options.(*schema.FileOptions)
if !ok {
return NewBadRequestError("", errors.New("Failed to load file options."))
}
// check whether the request is authorized to view the protected file
if options.Protected {
token := c.QueryParam("token")
adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token)
// create a copy of the cached request data and adjust it for the current auth model
requestInfo := *RequestInfo(c)
requestInfo.Admin = nil
requestInfo.AuthRecord = nil
if adminOrAuthRecord != nil {
if admin, _ := adminOrAuthRecord.(*models.Admin); admin != nil {
requestInfo.Admin = admin
} else if record, _ := adminOrAuthRecord.(*models.Record); record != nil {
requestInfo.AuthRecord = record
}
}
if ok, _ := api.app.Dao().CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
}
}
baseFilesPath := record.BaseFilesPath()
// fetch the original view file field related record
if collection.IsView() {
fileRecord, err := api.app.Dao().FindRecordByViewFile(collection.Id, fileField.Name, filename)
if err != nil {
return NewNotFoundError("", fmt.Errorf("Failed to fetch view file field record: %w", err))
}
baseFilesPath = fileRecord.BaseFilesPath()
}
fsys, err := api.app.NewFilesystem()
if err != nil {
return NewBadRequestError("Filesystem initialization failure.", err)
}
defer fs.Close()
defer fsys.Close()
originalPath := record.BaseFilesPath() + "/" + filename
originalPath := baseFilesPath + "/" + filename
servedPath := originalPath
servedName := filename
@@ -61,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)
}
@@ -70,15 +171,21 @@ func (api *fileApi) download(c echo.Context) error {
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
// add thumb size as file suffix
servedName = thumbSize + "_" + filename
servedPath = record.BaseFilesPath() + "/thumbs_" + filename + "/" + servedName
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
// check if the thumb exists:
// - if doesn't exist - create a new thumb with the specified thumb size
// - if exists - compare last modified dates to determine whether the thumb should be recreated
tAttrs, tAttrsErr := fs.Attributes(servedPath)
if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) {
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
}
}
}
@@ -92,13 +199,77 @@ func (api *fileApi) download(c echo.Context) error {
event.ServedPath = servedPath
event.ServedName = servedName
// clickjacking shouldn't be a concern when serving uploaded files,
// so it safe to unset the global X-Frame-Options to allow files embedding
// (note: it is out of the hook to allow users to customize the behavior)
c.Response().Header().Del("X-Frame-Options")
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
res := e.HttpContext.Response()
req := e.HttpContext.Request()
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
if e.HttpContext.Response().Committed {
return nil
}
if err := fsys.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
return NewNotFoundError("", err)
}
return nil
})
}
func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.Model, error) {
fileToken = strings.TrimSpace(fileToken)
if fileToken == "" {
return nil, errors.New("missing file token")
}
claims, _ := security.ParseUnverifiedJWT(strings.TrimSpace(fileToken))
tokenType := cast.ToString(claims["type"])
switch tokenType {
case tokens.TypeAdmin:
admin, err := api.app.Dao().FindAdminByToken(
fileToken,
api.app.Settings().AdminFileToken.Secret,
)
if err == nil && admin != nil {
return admin, nil
}
case tokens.TypeAuthRecord:
record, err := api.app.Dao().FindAuthRecordByToken(
fileToken,
api.app.Settings().RecordFileToken.Secret,
)
if err == nil && record != nil {
return record, nil
}
}
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
}
+284
View File
@@ -2,15 +2,95 @@ 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"
)
func TestFileToken(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/files/token",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
},
},
{
Name: "unauthorized with model and token via hook",
Method: http.MethodPost,
Url: "/api/files/token",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error {
record, _ := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
e.Model = record
e.Token = "test"
return nil
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"test"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
{
Name: "auth record",
Method: http.MethodPost,
Url: "/api/files/token",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
{
Name: "admin",
Method: http.MethodPost,
Url: "/api/files/token",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
@@ -176,9 +256,213 @@ func TestFileDownload(t *testing.T) {
"OnFileDownloadRequest": 1,
},
},
// protected file access checks
{
Name: "protected file - expired token",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - admin with expired file token",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - admin with valid file token",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - guest without view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - guest with view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock public view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - auth record without view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock restricted user view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = true")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - auth record with view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock user view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = false")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file in view (view's View API rule failure)",
Method: http.MethodGet,
Url: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file in view (view's View API rule success)",
Method: http.MethodGet,
Url: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
ExpectedStatus: 200,
ExpectedContent: []string{"test"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
}
for _, scenario := range scenarios {
// clone for the HEAD test (the same as the original scenario but without body)
head := scenario
head.Method = http.MethodHead
head.Name = ("(HEAD) " + scenario.Name)
head.ExpectedContent = nil
head.Test(t)
// regular request test
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)
}
}
}
+13 -5
View File
@@ -19,12 +19,20 @@ type healthApi struct {
app core.App
}
type healthCheckResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
CanBackup bool `json:"canBackup"`
} `json:"data"`
}
// healthCheck returns a 200 OK response if the server is healthy.
func (api *healthApi) healthCheck(c echo.Context) error {
payload := map[string]any{
"code": http.StatusOK,
"message": "API is healthy.",
}
resp := new(healthCheckResponse)
resp.Code = http.StatusOK
resp.Message = "API is healthy."
resp.Data.CanBackup = !api.app.Store().Has(core.StoreKeyActiveBackup)
return c.JSON(http.StatusOK, payload)
return c.JSON(http.StatusOK, resp)
}
+2
View File
@@ -16,6 +16,8 @@ func TestHealthAPI(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{
`"code":200`,
`"data":{`,
`"canBackup":true`,
},
},
}
+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)
}
},
+116 -82
View File
@@ -2,9 +2,10 @@ package apis
import (
"fmt"
"log"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
@@ -15,7 +16,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 +24,7 @@ const (
ContextAdminKey string = "admin"
ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection"
ContextExecStartKey string = "execStart"
)
// RequireGuestOnly middleware requires a request to NOT have a valid
@@ -59,9 +60,12 @@ func RequireGuestOnly() echo.MiddlewareFunc {
// specifying their names.
//
// Example:
// apis.RequireRecordAuth()
//
// apis.RequireRecordAuth()
//
// Or:
// apis.RequireRecordAuth("users", "supervisors")
//
// apis.RequireRecordAuth("users", "supervisors")
//
// To restrict the auth record only to the loaded context collection,
// use [apis.RequireSameContextRecordAuth()] instead.
@@ -83,7 +87,6 @@ func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
}
}
//
// RequireSameContextRecordAuth middleware requires a request to have
// a valid record Authorization header.
//
@@ -127,14 +130,17 @@ func RequireAdminAuth() echo.MiddlewareFunc {
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return next(c)
}
totalAdmins, err := app.Dao().TotalAdmins()
if err != nil {
return NewBadRequestError("Failed to fetch admins info.", err)
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil || totalAdmins == 0 {
if totalAdmins == 0 {
return next(c)
}
@@ -261,7 +267,7 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
}
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
return NewBadRequestError("Invalid collection type.", nil)
return NewBadRequestError("Unsupported collection type.", nil)
}
c.Set(ContextCollectionKey, collection)
@@ -280,84 +286,92 @@ 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.ToLower(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
requestUri := 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", requestUri),
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 + " "
if escaped, err := url.PathUnescape(requestUri); err == nil {
message += escaped
} else {
message += requestUri
}
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!
@@ -366,15 +380,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
return ip
}
if ip := r.Header.Get("Fly-Client-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
// extract the first non-empty leftmost-ish ip
ips := strings.Split(ipsList, ",")
// extract the rightmost ip
for i := len(ips) - 1; i >= 0; i-- {
ip := strings.TrimSpace(ips[i])
for _, ip := range ips {
ip = strings.TrimSpace(ip)
if ip != "" {
return ip
}
@@ -383,3 +401,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
return fallbackIp
}
// eagerRequestInfoCache ensures that the request data is cached in the request
// context to allow reading for example the json request body data more than once.
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
switch c.Request().Method {
// currently we are eagerly caching only the requests with body
case "POST", "PUT", "PATCH", "DELETE":
RequestInfo(c)
}
return next(c)
}
}
}
+310 -156
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v5"
@@ -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")
@@ -66,15 +71,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
connectEvent := &core.RealtimeConnectEvent{
HttpContext: c,
Client: client,
IdleTimeout: 5 * time.Minute,
}
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
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{
@@ -82,30 +86,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
Client: client,
Message: &subscriptions.Message{
Name: "PB_CONNECT",
Data: `{"clientId":"` + client.Id() + `"}`,
Data: []byte(`{"clientId":"` + client.Id() + `"}`),
},
}
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
w := e.HttpContext.Response()
fmt.Fprint(w, "id:"+client.Id()+"\n")
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
w.Write([]byte("id:" + client.Id() + "\n"))
w.Write([]byte("event:" + e.Message.Name + "\n"))
w.Write([]byte("data:"))
w.Write(e.Message.Data)
w.Write([]byte("\n\n"))
w.Flush()
return nil
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
}
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
}
// start an idle timer to keep track of inactive/forgotten connections
idleDuration := 5 * time.Minute
idleTimer := time.NewTimer(idleDuration)
idleTimeout := connectEvent.IdleTimeout
idleTimer := time.NewTimer(idleTimeout)
defer idleTimer.Stop()
for {
@@ -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
}
@@ -128,30 +134,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
}
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
w := e.HttpContext.Response()
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
w.Write([]byte("id:" + e.Client.Id() + "\n"))
w.Write([]byte("event:" + e.Message.Name + "\n"))
w.Write([]byte("data:"))
w.Write(e.Message.Data)
w.Write([]byte("\n\n"))
w.Flush()
return nil
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
}
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
log.Println("OnRealtimeAfterMessageSend error:", err)
}
idleTimer.Stop()
idleTimer.Reset(idleDuration)
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
}
}
@@ -190,7 +197,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
Subscriptions: form.Subscriptions,
}
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
return api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
// update auth state
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
@@ -201,21 +208,29 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// subscribe to the new subscriptions
e.Client.Subscribe(e.Subscriptions...)
return e.HttpContext.NoContent(http.StatusNoContent)
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
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
api.app.OnRealtimeAfterSubscribeRequest().Trigger(event)
}
return handlerErr
}
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
if clientModel != nil &&
clientModel.TableName() == newModel.TableName() &&
clientModel.GetId() == newModel.GetId() {
client.Set(contextKey, newModel)
}
}
@@ -227,7 +242,9 @@ func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel model
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == model.GetId() {
if clientModel != nil &&
clientModel.TableName() == model.TableName() &&
clientModel.GetId() == model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
}
@@ -238,7 +255,7 @@ func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model mo
func (api *realtimeApi) bindEvents() {
// update the clients that has admin or auth record association
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
if record := api.resolveRecord(e.Model); record != nil && record.Collection().IsAuth() {
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
}
@@ -251,8 +268,8 @@ func (api *realtimeApi) bindEvents() {
// remove the client(s) associated to the deleted admin or auth record
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
if collection := api.resolveRecordCollection(e.Model); collection != nil && collection.IsAuth() {
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, e.Model)
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
@@ -263,84 +280,98 @@ func (api *realtimeApi) bindEvents() {
})
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
log.Println(err)
if record := api.resolveRecord(e.Model); record != nil {
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
})
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
log.Println(err)
if record := api.resolveRecord(e.Model); record != nil {
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
})
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
log.Println(err)
if record := api.resolveRecord(e.Model); record != nil {
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
})
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.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
})
}
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
// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecord(model models.Model) (record *models.Record) {
record, _ = model.(*models.Record)
// check if it is custom Record model struct (ignore "private" tables)
if record == nil && !strings.HasPrefix(model.TableName(), "_") {
record, _ = api.app.Dao().FindRecordById(model.TableName(), model.GetId())
}
if accessRule == nil {
// only admins can access this record
return false
}
ruleFunc := func(q *dbx.SelectQuery) error {
if *accessRule == "" {
return nil // empty public rule
}
// emulate request data
requestData := &models.RequestData{
Method: "GET",
}
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, 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
return record
}
// resolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection *models.Collection) {
if record, ok := model.(*models.Record); ok {
collection = record.Collection()
} else if !strings.HasPrefix(model.TableName(), "_") {
// check if it is custom Record model struct (ignore "private" tables)
collection, _ = api.app.Dao().FindCollectionByNameOrId(model.TableName())
}
return collection
}
// 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) error {
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()
@@ -348,77 +379,158 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
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 {
if api.app.IsDebug() {
log.Println(err)
}
return err
}
encodedData := string(dataBytes)
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: encodedData,
}
// 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)
// 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 = string(newData)
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),
)
}
data.Record.IgnoreEmailVisibility(false) // restore
}
// 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)
})
}
}
routine.FireAndForget(func() {
if !client.IsDiscarded() {
client.Channel() <- msg
}
})
}
}
return nil
}
// 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 {
messages, ok := client.Get(key).([]subscriptions.Message)
if !ok {
continue
}
client.Unset(key)
client := client
routine.FireAndForget(func() {
for _, msg := range messages {
client.Send(msg)
}
})
}
return nil
}
type getter interface {
Get(string) any
}
@@ -436,3 +548,45 @@ 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
}
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
return false
}
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
}
+123 -13
View File
@@ -5,10 +5,13 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
@@ -20,6 +23,7 @@ func TestRealtimeConnect(t *testing.T) {
{
Method: http.MethodGet,
Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedContent: []string{
`id:`,
@@ -32,7 +36,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()))
}
@@ -42,6 +46,7 @@ func TestRealtimeConnect(t *testing.T) {
Name: "PB_CONNECT interrupt",
Method: http.MethodGet,
Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
@@ -56,7 +61,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()))
}
@@ -66,11 +71,11 @@ func TestRealtimeConnect(t *testing.T) {
Name: "Skipping/ignoring messages",
Method: http.MethodGet,
Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
@@ -78,7 +83,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()))
}
@@ -123,7 +128,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())
}
@@ -144,7 +149,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())
@@ -174,7 +179,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")
@@ -198,7 +203,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")
@@ -223,7 +228,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")
@@ -253,7 +258,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = authRecord
testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
@@ -282,7 +290,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
}
authRecord2.SetEmail("new@example.com")
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = authRecord2
testApp.OnModelAfterUpdate().Trigger(e)
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != authRecord2.Email() {
@@ -305,7 +316,10 @@ func TestRealtimeAdminDeleteEvent(t *testing.T) {
client.Set(apis.ContextAdminKey, admin)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = admin
testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
@@ -334,10 +348,106 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
}
admin2.Email = "new@example.com"
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = admin2
testApp.OnModelAfterUpdate().Trigger(e)
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email {
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
}
}
// Custom auth record model struct
// -------------------------------------------------------------------
var _ models.Model = (*CustomUser)(nil)
type CustomUser struct {
models.BaseModel
Email string `db:"email" json:"email"`
}
func (m *CustomUser) TableName() string {
return "users" // the name of your collection
}
func findCustomUserByEmail(dao *daos.Dao, email string) (*CustomUser, error) {
model := &CustomUser{}
err := dao.ModelQuery(model).
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
if err != nil {
t.Fatal(err)
}
// delete the custom user (should unset the client auth record)
if err := testApp.Dao().Delete(customUser); err != nil {
t.Fatal(err)
}
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
}
}
func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
if err != nil {
t.Fatal(err)
}
// change its email
customUser.Email = "new@example.com"
if err := testApp.Dao().Save(customUser); err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != customUser.Email {
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
}
}
+195 -120
View File
@@ -1,10 +1,12 @@
package apis
import (
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"sort"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -17,6 +19,7 @@ import (
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"golang.org/x/oauth2"
)
@@ -25,12 +28,15 @@ import (
func bindRecordAuthApi(app core.App, rg *echo.Group) {
api := recordAuthApi{app: app}
// global oauth2 subscription redirect handler
rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect)
// common collection record related routes
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app, models.CollectionTypeAuth),
)
subGroup.GET("/auth-methods", api.authMethods)
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
@@ -60,26 +66,23 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
event.Collection = record.Collection()
event.Record = record
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
return api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return api.app.OnRecordAfterAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
})
if handlerErr == nil {
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
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 {
@@ -91,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{},
}
@@ -112,37 +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"
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
})
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"))
}
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)
}
@@ -168,10 +197,18 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := new(core.RecordAuthWithOAuth2Event)
event.HttpContext = c
event.Collection = collection
event.ProviderName = form.Provider
event.IsNewRecord = false
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := RequestData(c)
requestData.Data = form.CreateData
event.IsNewRecord = true
// clone the current request data and assign the form create data as its body data
requestInfo := *RequestInfo(c)
requestInfo.Data = form.CreateData
createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -184,7 +221,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
}
if *collection.CreateRule != "" {
resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestInfo, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -204,14 +241,11 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
})
})
event := new(core.RecordAuthWithOAuth2Event)
event.HttpContext = c
event.Collection = collection
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
return func(data *forms.RecordOAuth2LoginData) error {
event.Record = data.Record
event.OAuth2User = data.OAuth2User
event.ProviderClient = data.ProviderClient
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
data.Record = e.Record
@@ -224,17 +258,21 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
e.Record = data.Record
e.OAuth2User = data.OAuth2User
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
meta := struct {
*auth.AuthUser
IsNew bool `json:"isNew"`
}{
AuthUser: e.OAuth2User,
IsNew: event.IsNewRecord,
}
return api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -264,17 +302,13 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
return NewBadRequestError("Failed to authenticate.", err)
}
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
return api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -309,30 +343,32 @@ 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()),
)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
// eagerly write 204 response and skip submit errors
// as a measure against emails enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
return submitErr
}
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
@@ -359,17 +395,17 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -399,30 +435,32 @@ 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()),
)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
// eagerly write 204 response and skip submit errors
// as a measure against users enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
return submitErr
}
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
@@ -449,17 +487,17 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -484,23 +522,23 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
event.Collection = collection
event.Record = record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to request email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
@@ -527,17 +565,17 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
return NewBadRequestError("Failed to confirm email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -601,17 +639,54 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
event.Record = record
event.ExternalAuth = externalAuth
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
return api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
return NewBadRequestError("Cannot unlink the external auth provider.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
return api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
if handlerErr == nil {
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
// -------------------------------------------------------------------
const oauth2SubscriptionTopic = "@oauth2"
func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
state := c.QueryParam("state")
code := c.QueryParam("code")
if code == "" || state == "" {
return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
}
return handlerErr
client, err := api.app.SubscriptionsBroker().ClientById(state)
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err)
}
data := map[string]string{
"state": state,
"code": code,
}
encodedData, err := json.Marshal(data)
if err != nil {
return NewBadRequestError("Failed to marshalize OAuth2 redirect data.", err)
}
msg := subscriptions.Message{
Name: oauth2SubscriptionTopic,
Data: encodedData,
}
client.Send(msg)
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
}
+336 -12
View File
@@ -1,14 +1,18 @@
package apis_test
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -36,6 +40,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
ExpectedContent: []string{
`"usernamePassword":true`,
`"emailPassword":true`,
`"onlyVerified":false`,
`"authProviders":[{`,
`"name":"gitlab"`,
`"state":`,
@@ -54,6 +59,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
ExpectedContent: []string{
`"usernamePassword":false`,
`"emailPassword":true`,
`"onlyVerified":true`,
`"authProviders":[]`,
},
},
@@ -208,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(`{
@@ -221,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,
@@ -278,6 +326,28 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordAuthRequest": 1,
},
},
// after hooks error checks
{
Name: "OnRecordAfterAuthWithPasswordRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
Body: strings.NewReader(`{
"identity":"test2_username",
"password":"1234567890"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -351,6 +421,60 @@ 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,
Url: "/api/collections/users/auth-refresh?expand=rel,missing",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthRefreshRequest": 1,
"OnRecordAfterAuthRefreshRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -492,10 +616,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
"password":"12345678",
"passwordConfirm":"12345678"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "different auth collection",
@@ -528,6 +650,29 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
},
{
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345678"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -669,10 +814,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "different auth collection",
@@ -730,6 +873,27 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
{
Name: "OnRecordAfterConfirmVerificationRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -813,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"`,
},
},
{
@@ -930,6 +1094,28 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
`"token":{"code":"validation_token_collection_mismatch"`,
},
},
{
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-email-change",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
"password":"1234567890"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmEmailChangeRequest": 1,
"OnRecordAfterConfirmEmailChangeRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -1081,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)
@@ -1127,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)
@@ -1138,6 +1324,144 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
}
},
},
{
Name: "OnRecordBeforeUnlinkExternalAuthRequest error response",
Method: http.MethodDelete,
Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterUnlinkExternalAuthRequest": 1,
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRecordAuthOAuth2Redirect(t *testing.T) {
c1 := subscriptions.NewDefaultClient()
c2 := subscriptions.NewDefaultClient()
c2.Subscribe("@oauth2")
c3 := subscriptions.NewDefaultClient()
c3.Subscribe("test1", "@oauth2")
c4 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2")
c5 := subscriptions.NewDefaultClient()
c5.Subscribe("@oauth2")
c5.Discard()
beforeTestFunc := func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(c1)
app.SubscriptionsBroker().Register(c2)
app.SubscriptionsBroker().Register(c3)
app.SubscriptionsBroker().Register(c4)
app.SubscriptionsBroker().Register(c5)
}
scenarios := []tests.ApiScenario{
{
Name: "no state query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "no code query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?state=" + c3.Id(),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing client",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=missing",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "discarded client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "client without @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
beforeTestFunc(t, app, e)
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
go func() {
defer cancelFunc()
L:
for {
select {
case <-c1.Channel():
t.Error("Unexpected c1 message")
break L
case <-c2.Channel():
t.Error("Unexpected c2 message")
break L
case msg := <-c3.Channel():
if msg.Name != "@oauth2" {
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
}
expectedParams := []string{`"state"`, `"code"`}
for _, p := range expectedParams {
if !strings.Contains(string(msg.Data), p) {
t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
}
}
break L
case <-c4.Channel():
t.Error("Unexpected c4 message")
break L
case <-c5.Channel():
t.Error("Unexpected c5 message")
break L
case <-ctx.Done():
t.Error("Context timeout reached")
break L
}
}
}()
},
ExpectedStatus: http.StatusTemporaryRedirect,
},
}
for _, scenario := range scenarios {
+99 -105
View File
@@ -2,9 +2,8 @@ package apis
import (
"fmt"
"log"
"log/slog"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -16,8 +15,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) {
@@ -26,14 +23,13 @@ func bindRecordCrudApi(app core.App, rg *echo.Group) {
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app),
)
subGroup.GET("/records", api.list)
subGroup.POST("/records", api.create)
subGroup.GET("/records/:id", api.view)
subGroup.PATCH("/records/:id", api.update)
subGroup.DELETE("/records/:id", api.delete)
subGroup.GET("/records", api.list, LoadCollectionContext(app))
subGroup.GET("/records/:id", api.view, LoadCollectionContext(app))
subGroup.POST("/records", api.create, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.PATCH("/records/:id", api.update, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.DELETE("/records/:id", api.delete, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
}
type recordApi struct {
@@ -46,14 +42,14 @@ func (api *recordApi) list(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
requestInfo := RequestInfo(c)
// forbid users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil {
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
return err
}
requestData := RequestData(c)
if requestData.Admin == nil && collection.ListRule == nil {
if requestInfo.Admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
@@ -61,28 +57,25 @@ func (api *recordApi) list(c echo.Context) error {
fieldsResolver := resolvers.NewRecordFieldResolver(
api.app.Dao(),
collection,
requestData,
requestInfo,
// hidden fields are searchable only by admins
requestData.Admin != nil,
requestInfo.Admin != nil,
)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection))
if requestData.Admin == nil && collection.ListRule != nil {
if requestInfo.Admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
records := []*models.Record{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
if err != nil {
return NewBadRequestError("Invalid filter parameters.", err)
return NewBadRequestError("", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
result.Items = records
event := new(core.RecordsListEvent)
event.HttpContext = c
event.Collection = collection
@@ -90,8 +83,12 @@ func (api *recordApi) list(c echo.Context) error {
event.Result = result
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
log.Println(err)
if e.HttpContext.Response().Committed {
return nil
}
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)
@@ -109,16 +106,16 @@ func (api *recordApi) view(c echo.Context) error {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.ViewRule == nil {
if requestInfo.Admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
if requestInfo.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
@@ -140,8 +137,17 @@ func (api *recordApi) view(c echo.Context) error {
event.Record = record
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
if e.HttpContext.Response().Committed {
return nil
}
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)
@@ -154,23 +160,23 @@ func (api *recordApi) create(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.CreateRule == nil {
if requestInfo.Admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
hasFullManageAccess := requestData.Admin != nil
hasFullManageAccess := requestInfo.Admin != nil
// temporary save the record and check it against the create rule
if requestData.Admin == nil && collection.CreateRule != nil {
if requestInfo.Admin == nil && collection.CreateRule != nil {
testRecord := models.NewRecord(collection)
// replace modifiers fields so that the resolved value is always
// available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
// available when accessing requestInfo.Data using just the field name
if requestInfo.HasModifierDataKeys() {
requestInfo.Data = testRecord.ReplaceModifers(requestInfo.Data)
}
testForm := forms.NewRecordUpsert(api.app, testRecord)
@@ -184,7 +190,7 @@ func (api *recordApi) create(c echo.Context) error {
return nil // no create rule to resolve
}
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -199,7 +205,7 @@ func (api *recordApi) create(c echo.Context) error {
if err != nil {
return fmt.Errorf("DrySubmit create rule failure: %w", err)
}
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestInfo)
return nil
})
@@ -221,9 +227,10 @@ func (api *recordApi) create(c echo.Context) error {
event.HttpContext = c
event.Collection = collection
event.Record = record
event.UploadedFiles = form.FilesToUpload()
// create the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
@@ -232,22 +239,25 @@ 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 e.HttpContext.JSON(http.StatusOK, e.Record)
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordApi) update(c echo.Context) error {
@@ -261,26 +271,26 @@ func (api *recordApi) update(c echo.Context) error {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.UpdateRule == nil {
if requestInfo.Admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
// eager fetch the record so that the modifier field values are replaced
// and available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
// and available when accessing requestInfo.Data using just the field name
if requestInfo.HasModifierDataKeys() {
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
requestData.Data = record.ReplaceModifers(requestData.Data)
requestInfo.Data = record.ReplaceModifers(requestInfo.Data)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
if requestInfo.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -298,7 +308,7 @@ func (api *recordApi) update(c echo.Context) error {
}
form := forms.NewRecordUpsert(api.app, record)
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
form.SetFullManageAccess(requestInfo.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestInfo))
// load request
if err := form.LoadRequest(c.Request(), ""); err != nil {
@@ -309,9 +319,10 @@ func (api *recordApi) update(c echo.Context) error {
event.HttpContext = c
event.Collection = collection
event.Record = record
event.UploadedFiles = form.FilesToUpload()
// update the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
@@ -320,22 +331,25 @@ 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 e.HttpContext.JSON(http.StatusOK, e.Record)
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordApi) delete(c echo.Context) error {
@@ -349,16 +363,16 @@ func (api *recordApi) delete(c echo.Context) error {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.DeleteRule == nil {
if requestInfo.Admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
if requestInfo.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
@@ -379,38 +393,18 @@ func (api *recordApi) delete(c echo.Context) error {
event.Collection = collection
event.Record = record
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
return api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
// delete the record
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // admins are allowed to query everything
}
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
forbiddenFields := []string{"@collection.", "@request."}
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
+258 -9
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"net/url"
"os"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -41,9 +43,16 @@ func TestRecordCrudList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection but with admin only filter/sort (aka. @collection)",
Name: "public collection but with admin only filter param (aka. @collection, @request, etc.)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'",
Url: "/api/collections/demo2/records?filter=%40collection.demo2.title='test1'",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection but with admin only sort param (aka. @collection, @request, etc.)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records?sort=@request.auth.title",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
@@ -256,7 +265,7 @@ func TestRecordCrudList(t *testing.T) {
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
// auth collection checks
// auth collection
// -----------------------------------------------------------
{
Name: "check email visibility as guest",
@@ -403,6 +412,79 @@ func TestRecordCrudList(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
// view collection
// -----------------------------------------------------------
{
Name: "public view records",
Method: http.MethodGet,
Url: "/api/collections/view2/records?filter=state=false",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":2`,
`"items":[{`,
`"id":"al1h9ijdeojtsjy"`,
`"id":"imy661ixudk5izi"`,
},
NotExpectedContent: []string{
`"created"`,
`"updated"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "guest that doesn't match the view collection list rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":0`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "authenticated record that matches the view collection list rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records",
RequestHeaders: map[string]string{
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":1`,
`"items":[{`,
`"id":"84nmscqy84lsi1t"`,
`"bool":true`,
},
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 {
@@ -531,7 +613,7 @@ func TestRecordCrudView(t *testing.T) {
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
// auth collection checks
// auth collection
// -----------------------------------------------------------
{
Name: "check email visibility as guest",
@@ -629,6 +711,59 @@ func TestRecordCrudView(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
// view collection
// -----------------------------------------------------------
{
Name: "public view record",
Method: http.MethodGet,
Url: "/api/collections/view2/records/84nmscqy84lsi1t",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"84nmscqy84lsi1t"`,
`"state":true`,
`"file_many":["`,
`"rel_many":["`,
},
NotExpectedContent: []string{
`"created"`,
`"updated"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "guest that doesn't match the view collection view rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authenticated record that matches the view collection view rule",
Method: http.MethodGet,
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
RequestHeaders: map[string]string{
// users, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"84nmscqy84lsi1t"`,
`"bool":true`,
`"text":"`,
},
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 {
@@ -690,6 +825,13 @@ func TestRecordCrudDelete(t *testing.T) {
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to delete a view collection record",
Method: http.MethodDelete,
Url: "/api/collections/view1/records/imy661ixudk5izi",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection record delete",
Method: http.MethodDelete,
@@ -729,6 +871,27 @@ func TestRecordCrudDelete(t *testing.T) {
"OnRecordBeforeDeleteRequest": 1,
},
},
{
Name: "OnRecordAfterDeleteRequest error response",
Method: http.MethodDelete,
Url: "/api/collections/clients/records/o1y0dd0spd786md",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
},
{
Name: "authenticated record that match the collection delete rule",
Method: http.MethodDelete,
@@ -747,7 +910,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
@@ -834,7 +997,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 {
@@ -885,6 +1048,14 @@ func TestRecordCrudCreate(t *testing.T) {
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to create a new view collection record",
Method: http.MethodPost,
Url: "/api/collections/view1/records",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPost,
@@ -1058,6 +1229,39 @@ func TestRecordCrudCreate(t *testing.T) {
"OnModelAfterCreate": 1,
},
},
{
Name: "unique field error check",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"title":"test2"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"title":{`,
`"code":"validation_not_unique"`,
},
},
{
Name: "OnRecordAfterCreateRequest error response",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{"title":"new"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
// ID checks
// -----------------------------------------------------------
@@ -1428,6 +1632,14 @@ func TestRecordCrudUpdate(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "trying to update a view collection record",
Method: http.MethodPatch,
Url: "/api/collections/view1/records/imy661ixudk5izi",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPatch,
@@ -1608,6 +1820,25 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnModelAfterUpdate": 1,
},
},
{
Name: "OnRecordAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{"title":"new"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "try to change the id of an existing record",
Method: http.MethodPatch,
@@ -1624,6 +1855,24 @@ func TestRecordCrudUpdate(t *testing.T) {
`"id":{"code":"validation_in_invalid"`,
},
},
{
Name: "unique field error check",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/llvuca81nly1qls",
Body: strings.NewReader(`{
"title":"test2"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"title":{`,
`"code":"validation_not_unique"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
},
},
// check whether if @request.data modifer fields are properly resolved
// -----------------------------------------------------------
@@ -1790,7 +2039,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.")
@@ -1833,7 +2082,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.")
@@ -1895,7 +2144,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.")
+108 -33
View File
@@ -3,6 +3,7 @@ package apis
import (
"fmt"
"log"
"log/slog"
"net/http"
"strings"
@@ -13,26 +14,48 @@ 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 ContextRequestDataKey = "requestData"
const ContextRequestInfoKey = "requestInfo"
// RequestData exports cached common request data fields
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).")
return RequestInfo(c)
}
// RequestInfo exports cached common request data fields
// (query, body, logged auth state, etc.) from the provided context.
func RequestData(c echo.Context) *models.RequestData {
func RequestInfo(c echo.Context) *models.RequestInfo {
// return cached to avoid copying the body multiple times
if v := c.Get(ContextRequestDataKey); v != nil {
if data, ok := v.(*models.RequestData); ok {
if v := c.Get(ContextRequestInfoKey); v != nil {
if data, ok := v.(*models.RequestInfo); ok {
// refresh auth state
data.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
data.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
return data
}
}
result := &models.RequestData{
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
result := &models.RequestInfo{
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
Headers: map[string]any{},
}
// extract the first value of all headers and normalizes the keys
// ("X-Token" is converted to "x_token")
for k, v := range c.Request().Header {
if len(v) > 0 {
result.Headers[inflector.Snakecase(k)] = v[0]
}
}
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
@@ -40,12 +63,24 @@ func RequestData(c echo.Context) *models.RequestData {
echo.BindQueryParams(c, &result.Query)
rest.BindBody(c, &result.Data)
c.Set(ContextRequestDataKey, result)
c.Set(ContextRequestInfoKey, result)
return result
}
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
// RecordAuthResponse writes standardised json record auth response
// into the specified request context.
func RecordAuthResponse(
app core.App,
c echo.Context,
authRecord *models.Record,
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)
@@ -59,6 +94,10 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
event.Meta = meta
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
@@ -66,16 +105,16 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
// create a copy of the cached request data and adjust it to the current auth record
requestData := *RequestData(e.HttpContext)
requestData.Admin = nil
requestData.AuthRecord = e.Record
requestInfo := *RequestInfo(e.HttpContext)
requestInfo.Admin = nil
requestInfo.AuthRecord = e.Record
failed := app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(app.Dao(), &requestData),
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))
}
}
@@ -88,6 +127,12 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
result["meta"] = e.Meta
}
for _, f := range finalizers {
if err := f(e.Token); err != nil {
return err
}
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
@@ -105,19 +150,21 @@ func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultE
// - ensures that the emails of the auth records and their expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
return fmt.Errorf("Failed to resolve email visibility: %w", err)
}
expands := defaultExpands
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
if param := c.QueryParam(expandQueryParam); param != "" {
expands = append(expands, strings.Split(param, ",")...)
}
if len(expands) == 0 {
return nil // nothing to expand
}
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
if len(errs) > 0 {
return fmt.Errorf("Failed to expand: %v", errs)
}
@@ -128,11 +175,11 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
// expandFetch is the records fetch function that is used to expand related records.
func expandFetch(
dao *daos.Dao,
requestData *models.RequestData,
requestInfo *models.RequestInfo,
) daos.ExpandFetchFunc {
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
if requestData.Admin != nil {
if requestInfo.Admin != nil {
return nil // admins can access everything
}
@@ -141,7 +188,7 @@ func expandFetch(
}
if *relCollection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestInfo, true)
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
@@ -154,7 +201,7 @@ func expandFetch(
})
if err == nil && len(records) > 0 {
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
}
return records, err
@@ -168,13 +215,13 @@ func expandFetch(
func autoIgnoreAuthRecordsEmailVisibility(
dao *daos.Dao,
records []*models.Record,
requestData *models.RequestData,
requestInfo *models.RequestInfo,
) error {
if len(records) == 0 || !records[0].Collection().IsAuth() {
return nil // nothing to check
}
if requestData.Admin != nil {
if requestInfo.Admin != nil {
for _, rec := range records {
rec.IgnoreEmailVisibility(true)
}
@@ -190,8 +237,8 @@ func autoIgnoreAuthRecordsEmailVisibility(
recordIds[i] = rec.Id
}
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
if requestInfo != nil && requestInfo.AuthRecord != nil && mappedRecords[requestInfo.AuthRecord.Id] != nil {
mappedRecords[requestInfo.AuthRecord.Id].IgnoreEmailVisibility(true)
}
authOptions := collection.AuthOptions()
@@ -207,7 +254,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestInfo, true)
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
if err != nil {
return err
@@ -236,7 +283,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
func hasAuthManageAccess(
dao *daos.Dao,
record *models.Record,
requestData *models.RequestData,
requestInfo *models.RequestInfo,
) bool {
if !record.Collection().IsAuth() {
return false
@@ -248,12 +295,12 @@ func hasAuthManageAccess(
return false // only for admins (manageRule can't be empty)
}
if requestData == nil || requestData.AuthRecord == nil {
if requestInfo == nil || requestInfo.AuthRecord == nil {
return false // no auth record
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
return err
@@ -267,3 +314,31 @@ func hasAuthManageAccess(
return findErr == nil
}
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
var adminOnlyRuleFields = []string{"@collection.", "@request."}
// @todo consider moving the rules check to the RecordFieldResolver.
//
// checkForAdminOnlyRuleFields loosely checks and returns an error if
// the provided RequestInfo contains rule fields that only the admin can use.
func checkForAdminOnlyRuleFields(requestInfo *models.RequestInfo) error {
if requestInfo.Admin != nil || len(requestInfo.Query) == 0 {
return nil // admin or nothing to check
}
for _, param := range ruleQueryParams {
v, _ := requestInfo.Query[param].(string)
if v == "" {
continue
}
for _, field := range adminOnlyRuleFields {
if strings.Contains(v, field) {
return NewForbiddenError("Only admins can filter by "+field, nil)
}
}
}
return nil
}
+20 -3
View File
@@ -13,10 +13,11 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestRequestData(t *testing.T) {
func TestRequestInfo(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("X-Token-Test", "123")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
@@ -28,16 +29,22 @@ func TestRequestData(t *testing.T) {
dummyAdmin.Id = "id2"
c.Set(apis.ContextAdminKey, dummyAdmin)
result := apis.RequestData(c)
result := apis.RequestInfo(c)
if result == nil {
t.Fatal("Expected *models.RequestData instance, got nil")
t.Fatal("Expected *models.RequestInfo instance, got nil")
}
if result.Method != http.MethodPost {
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
}
rawHeaders, _ := json.Marshal(result.Headers)
expectedHeaders := `{"content_type":"application/json","x_token_test":"123"}`
if v := string(rawHeaders); v != expectedHeaders {
t.Fatalf("Expected Query %v, got %v", expectedHeaders, v)
}
rawQuery, _ := json.Marshal(result.Query)
expectedQuery := `{"test":"123"}`
if v := string(rawQuery); v != expectedQuery {
@@ -76,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
@@ -90,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,
+277
View File
@@ -0,0 +1,277 @@
package apis
import (
"context"
"crypto/tls"
"log"
"net"
"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"
)
// ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
ShowStartBanner bool
// HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`).
HttpAddr string
// 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{"*"}
}
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
return nil, err
}
// reload app settings in case a new default value was set with a migration
// (or if this is the first time the init migration was executed)
if err := app.RefreshSettings(); err != nil {
color.Yellow("=====================================")
color.Yellow("WARNING: Settings load error! \n%v", err)
color.Yellow("Fallback to the application defaults.")
color.Yellow("=====================================")
}
router, err := InitApi(app)
if err != nil {
return nil, err
}
// configure cors
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
Skipper: middleware.DefaultSkipper,
AllowOrigins: config.AllowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
// start http server
// ---
mainAddr := config.HttpAddr
if config.HttpsAddr != "" {
mainAddr = config.HttpsAddr
}
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(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},
},
ReadTimeout: 10 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Addr: mainAddr,
BaseContext: func(l net.Listener) context.Context {
return baseCtx
},
}
serveEvent := &core.ServeEvent{
App: app,
Router: router,
Server: server,
CertManager: certManager,
}
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
return nil, err
}
if config.ShowStartBanner {
schema := "http"
addr := server.Addr
if config.HttpsAddr != "" {
schema = "https"
if len(config.CertificateDomains) > 0 {
addr = config.CertificateDomains[0]
}
}
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, addr),
)
regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
}
// 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
if config.HttpAddr != "" {
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
}
return server, server.ListenAndServeTLS("", "")
}
// OR start HTTP server
return server, server.ListenAndServe()
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func runMigrations(app core.App) error {
connections := []migrationsConnection{
{
DB: app.DB(),
MigrationsList: migrations.AppMigrations,
},
{
DB: app.LogsDB(),
MigrationsList: logs.LogsMigrations,
},
}
for _, c := range connections {
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
if err != nil {
return err
}
if _, err := runner.Up(); err != nil {
return err
}
}
return nil
}
+56 -36
View File
@@ -1,8 +1,6 @@
package apis
import (
"fmt"
"log"
"net/http"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -10,7 +8,6 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/security"
)
// bindSettingsApi registers the settings api endpoints.
@@ -22,6 +19,7 @@ func bindSettingsApi(app core.App, rg *echo.Group) {
subGroup.PATCH("", api.set)
subGroup.POST("/test/s3", api.testS3)
subGroup.POST("/test/email", api.testEmail)
subGroup.POST("/apple/generate-client-secret", api.generateAppleClientSecret)
}
type settingsApi struct {
@@ -39,6 +37,10 @@ func (api *settingsApi) list(c echo.Context) error {
event.RedactedSettings = settings
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
})
}
@@ -56,7 +58,7 @@ func (api *settingsApi) set(c echo.Context) error {
event.OldSettings = api.app.Settings()
// update the settings
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
event.NewSettings = s
@@ -65,47 +67,40 @@ func (api *settingsApi) set(c echo.Context) error {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return NewBadRequestError("", err)
}
return api.app.OnSettingsAfterUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return NewBadRequestError("", err)
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
})
})
}
})
if submitErr == nil {
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *settingsApi) testS3(c echo.Context) error {
if !api.app.Settings().S3.Enabled {
return NewBadRequestError("S3 storage is not enabled.", nil)
form := forms.NewTestS3Filesystem(api.app)
// load request
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
fs, err := api.app.NewFilesystem()
if err != nil {
return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
}
defer fs.Close()
// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
return NewBadRequestError("Failed to test the S3 filesystem.", fErr)
}
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
testFileKey := testPrefix + "/test.txt"
// try to upload a test file
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
// test prefix deletion (ensures that both bucket list and delete works)
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
// mailer error
return NewBadRequestError("Failed to test the S3 filesystem. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
@@ -121,8 +116,8 @@ func (api *settingsApi) testEmail(c echo.Context) error {
// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
// form error
return NewBadRequestError("Failed to send the test email.", fErr)
}
@@ -132,3 +127,28 @@ func (api *settingsApi) testEmail(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
form := forms.NewAppleClientSecretCreate(api.app)
// load request
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
// generate
secret, err := form.Submit()
if err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
return NewBadRequestError("Invalid client secret data.", fErr)
}
// secret generation error
return NewBadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
}
return c.JSON(http.StatusOK, map[string]any{
"secret": secret,
})
}
+233 -15
View File
@@ -1,11 +1,19 @@
package apis_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
@@ -41,12 +49,15 @@ func TestSettingsList(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
`"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -61,8 +72,16 @@ func TestSettingsList(t *testing.T) {
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"authentikAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -114,12 +133,15 @@ func TestSettingsSet(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
`"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -133,8 +155,16 @@ func TestSettingsSet(t *testing.T) {
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"authentikAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -174,12 +204,15 @@ func TestSettingsSet(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
`"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -194,8 +227,16 @@ func TestSettingsSet(t *testing.T) {
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"authentikAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -207,6 +248,28 @@ func TestSettingsSet(t *testing.T) {
"OnSettingsAfterUpdateRequest": 1,
},
},
{
Name: "OnSettingsAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnSettingsAfterUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnSettingsBeforeUpdateRequest": 1,
"OnSettingsAfterUpdateRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -234,14 +297,44 @@ func TestSettingsTestS3(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (no s3)",
Name: "authorized as admin (missing body + no s3)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"filesystem":{`,
},
},
{
Name: "authorized as admin (invalid filesystem)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"invalid"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"filesystem":{`,
},
},
{
Name: "authorized as admin (valid filesystem and no s3)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"storage"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
},
}
@@ -313,13 +406,17 @@ 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)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[verification] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
@@ -344,13 +441,17 @@ 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)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[password-reset] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
@@ -375,13 +476,17 @@ 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)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[email-change] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
}
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
@@ -401,3 +506,116 @@ func TestSettingsTestEmail(t *testing.T) {
scenario.Test(t)
}
}
func TestGenerateAppleClientSecret(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedKey,
},
)
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (invalid body)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (empty json)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"clientId":{"code":"validation_required"`,
`"teamId":{"code":"validation_required"`,
`"keyId":{"code":"validation_required"`,
`"privateKey":{"code":"validation_required"`,
`"duration":{"code":"validation_required"`,
},
},
{
Name: "authorized as admin (invalid data)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{
"clientId": "",
"teamId": "123456789",
"keyId": "123456789",
"privateKey": "invalid",
"duration": -1
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"clientId":{"code":"validation_required"`,
`"teamId":{"code":"validation_length_invalid"`,
`"keyId":{"code":"validation_length_invalid"`,
`"privateKey":{"code":"validation_match_invalid"`,
`"duration":{"code":"validation_min_greater_equal_than_required"`,
},
},
{
Name: "authorized as admin (valid data)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(fmt.Sprintf(`{
"clientId": "123",
"teamId": "1234567890",
"keyId": "1234567891",
"privateKey": %q,
"duration": 1
}`, privatePem)),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"secret":"`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+135
View File
@@ -0,0 +1,135 @@
package cmd
import (
"errors"
"fmt"
"github.com/fatih/color"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/spf13/cobra"
)
// NewAdminCommand creates and returns new command for managing
// admin accounts (create, update, delete).
func NewAdminCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "admin",
Short: "Manages admin accounts",
}
command.AddCommand(adminCreateCommand(app))
command.AddCommand(adminUpdateCommand(app))
command.AddCommand(adminDeleteCommand(app))
return command
}
func adminCreateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "create",
Example: "admin create test@example.com 1234567890",
Short: "Creates a new admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
}
if len(args[1]) < 8 {
return errors.New("The password must be at least 8 chars long.")
}
admin := &models.Admin{}
admin.Email = args[0]
admin.SetPassword(args[1])
if err := app.Dao().SaveAdmin(admin); err != nil {
return fmt.Errorf("Failed to create new admin account: %v", err)
}
color.Green("Successfully created new admin %s!", admin.Email)
return nil
},
}
return command
}
func adminUpdateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "update",
Example: "admin update test@example.com 1234567890",
Short: "Changes the password of a single admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
}
if len(args[1]) < 8 {
return errors.New("The new password must be at least 8 chars long.")
}
admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil {
return fmt.Errorf("Admin with email %s doesn't exist.", args[0])
}
admin.SetPassword(args[1])
if err := app.Dao().SaveAdmin(admin); err != nil {
return fmt.Errorf("Failed to change admin %s password: %v", admin.Email, err)
}
color.Green("Successfully changed admin %s password!", admin.Email)
return nil
},
}
return command
}
func adminDeleteCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "delete",
Example: "admin delete test@example.com",
Short: "Deletes an existing admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.")
}
admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil {
color.Yellow("Admin %s is already deleted.", args[0])
return nil
}
if err := app.Dao().DeleteAdmin(admin); err != nil {
return fmt.Errorf("Failed to delete admin %s: %v", admin.Email, err)
}
color.Green("Successfully deleted admin %s!", admin.Email)
return nil
},
}
return command
}
+219
View File
@@ -0,0 +1,219 @@
package cmd_test
import (
"testing"
"github.com/pocketbase/pocketbase/cmd"
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminCreateCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
email string
password string
expectError bool
}{
{
"empty email and password",
"",
"",
true,
},
{
"empty email",
"",
"1234567890",
true,
},
{
"invalid email",
"invalid",
"1234567890",
true,
},
{
"duplicated email",
"test@example.com",
"1234567890",
true,
},
{
"empty password",
"test@example.com",
"",
true,
},
{
"short password",
"test_new@example.com",
"1234567",
true,
},
{
"valid email and password",
"test_new@example.com",
"12345678",
false,
},
}
for _, s := range scenarios {
command := cmd.NewAdminCommand(app)
command.SetArgs([]string{"create", s.email, s.password})
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
// check whether the admin account was actually created
admin, err := app.Dao().FindAdminByEmail(s.email)
if err != nil {
t.Errorf("[%s] Failed to fetch created admin %s: %v", s.name, s.email, err)
} else if !admin.ValidatePassword(s.password) {
t.Errorf("[%s] Expected the admin password to match", s.name)
}
}
}
func TestAdminUpdateCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
email string
password string
expectError bool
}{
{
"empty email and password",
"",
"",
true,
},
{
"empty email",
"",
"1234567890",
true,
},
{
"invalid email",
"invalid",
"1234567890",
true,
},
{
"nonexisting admin",
"test_missing@example.com",
"1234567890",
true,
},
{
"empty password",
"test@example.com",
"",
true,
},
{
"short password",
"test_new@example.com",
"1234567",
true,
},
{
"valid email and password",
"test@example.com",
"12345678",
false,
},
}
for _, s := range scenarios {
command := cmd.NewAdminCommand(app)
command.SetArgs([]string{"update", s.email, s.password})
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
// check whether the admin password was actually changed
admin, err := app.Dao().FindAdminByEmail(s.email)
if err != nil {
t.Errorf("[%s] Failed to fetch admin %s: %v", s.name, s.email, err)
} else if !admin.ValidatePassword(s.password) {
t.Errorf("[%s] Expected the admin password to match", s.name)
}
}
}
func TestAdminDeleteCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
email string
expectError bool
}{
{
"empty email",
"",
true,
},
{
"invalid email",
"invalid",
true,
},
{
"nonexisting admin",
"test_missing@example.com",
false,
},
{
"existing admin",
"test@example.com",
false,
},
}
for _, s := range scenarios {
command := cmd.NewAdminCommand(app)
command.SetArgs([]string{"delete", s.email})
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
// check whether the admin account was actually deleted
if _, err := app.Dao().FindAdminByEmail(s.email); err == nil {
t.Errorf("[%s] Expected the admin account to be deleted", s.name)
}
}
}
+25 -134
View File
@@ -1,25 +1,12 @@
package cmd
import (
"crypto/tls"
"log"
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
// NewServeCommand creates and returns new command responsible for
@@ -30,99 +17,34 @@ 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) {
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
panic(err)
}
// reload app settings in case a new default value was set with a migration
// (or if this is the first time the init migration was executed)
if err := app.RefreshSettings(); err != nil {
color.Yellow("=====================================")
color.Yellow("WARNING: Settings load error! \n%v", err)
color.Yellow("Fallback to the application defaults.")
color.Yellow("=====================================")
}
router, err := apis.InitApi(app)
if err != nil {
panic(err)
}
// configure cors
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
Skipper: middleware.DefaultSkipper,
AllowOrigins: allowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
// start http server
// ---
mainAddr := httpAddr
if httpsAddr != "" {
mainAddr = httpsAddr
}
mainHost, _, _ := net.SplitHostPort(mainAddr)
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
}
serverConfig := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Addr: mainAddr,
}
if showStartBanner {
schema := "http"
if httpsAddr != "" {
schema = "https"
// set default listener addresses if at least one domain is specified
if len(args) > 0 {
if httpAddr == "" {
httpAddr = "0.0.0.0:80"
}
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, serverConfig.Addr),
)
regular := color.New()
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
}
var serveErr error
if httpsAddr != "" {
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
if httpAddr != "" {
go http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil))
if httpsAddr == "" {
httpsAddr = "0.0.0.0:443"
}
// start HTTPS server
serveErr = serverConfig.ListenAndServeTLS("", "")
} else {
// start HTTP server
serveErr = serverConfig.ListenAndServe()
if httpAddr == "" {
httpAddr = "127.0.0.1:8090"
}
}
if serveErr != http.ErrServerClosed {
log.Fatalln(serveErr)
_, err := apis.Serve(app, apis.ServeConfig{
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
ShowStartBanner: showStartBanner,
AllowedOrigins: allowedOrigins,
CertificateDomains: args,
})
if err != http.ErrServerClosed {
log.Fatalln(err)
}
},
}
@@ -137,47 +59,16 @@ 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
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func runMigrations(app core.App) error {
connections := []migrationsConnection{
{
DB: app.DB(),
MigrationsList: migrations.AppMigrations,
},
{
DB: app.LogsDB(),
MigrationsList: logs.LogsMigrations,
},
}
for _, c := range connections {
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
if err != nil {
return err
}
if _, err := runner.Up(); err != nil {
return err
}
}
return nil
}
-444
View File
@@ -1,444 +0,0 @@
package cmd
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cobra"
)
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
//
// NB! It will be removed in v0.9+
func NewTempUpgradeCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "upgrade",
Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
Long: `
Upgrades your existing pb_data to be compatible with the v0.8.x changes
Prerequisites and caveats:
- already upgraded to v0.7.*
- no existing users collection
- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
`,
Run: func(command *cobra.Command, args []string) {
if err := upgrade(app); err != nil {
color.Red("Error: %v", err)
}
},
}
return command
}
func upgrade(app core.App) error {
if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
}
return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
if err := migrateCollections(txDao); err != nil {
return err
}
if err := migrateUsers(app, txDao); err != nil {
return err
}
if err := resetMigrationsTable(txDao); err != nil {
return err
}
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Println("The pb_data upgrade completed successfully!")
bold.Println("You can now start the application as usual with the 'serve' command.")
bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
fmt.Println()
return nil
})
}
// -------------------------------------------------------------------
func migrateCollections(txDao *daos.Dao) error {
// add new collection columns
if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
return err
}
if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
return err
}
ruleReplacements := []struct {
old string
new string
}{
{"expand", "expand2"},
{"collecitonId", "collectionId2"},
{"collecitonName", "collectionName2"},
{"profile.userId", "profile.id"},
// @collection.*
{"@collection.profiles.userId", "@collection.users.id"},
{"@collection.profiles.username", "@collection.users.username2"},
{"@collection.profiles.email", "@collection.users.email2"},
{"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
{"@collection.profiles.verified", "@collection.users.verified2"},
{"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
{"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
{"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
{"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
{"@collection.profiles.", "@collection.users."},
// @request.*
{"@request.user.profile.userId", "@request.auth.id"},
{"@request.user.profile.username", "@request.auth.username2"},
{"@request.user.profile.email", "@request.auth.email2"},
{"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
{"@request.user.profile.verified", "@request.auth.verified2"},
{"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
{"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
{"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
{"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
{"@request.user.profile.", "@request.auth."},
{"@request.user", "@request.auth"},
}
collections := []*models.Collection{}
if err := txDao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, collection := range collections {
collection.Type = models.CollectionTypeBase
collection.NormalizeOptions()
// rename profile fields
// ---
fieldsToRename := []string{
"collectionId",
"collectionName",
"expand",
}
if collection.Name == "profiles" {
fieldsToRename = append(fieldsToRename,
"username",
"email",
"emailVisibility",
"verified",
"tokenKey",
"passwordHash",
"lastResetSentAt",
"lastVerificationSentAt",
)
}
for _, name := range fieldsToRename {
f := collection.Schema.GetFieldByName(name)
if f != nil {
color.Blue("[%s - renamed field]", collection.Name)
color.Yellow(" - old: %s", f.Name)
color.Green(" - new: %s2", f.Name)
fmt.Println()
f.Name += "2"
}
}
// ---
// replace rule fields
// ---
rules := map[string]*string{
"ListRule": collection.ListRule,
"ViewRule": collection.ViewRule,
"CreateRule": collection.CreateRule,
"UpdateRule": collection.UpdateRule,
"DeleteRule": collection.DeleteRule,
}
for ruleKey, rule := range rules {
if rule == nil || *rule == "" {
continue
}
originalRule := *rule
for _, replacement := range ruleReplacements {
re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
*rule = re.ReplaceAllString(*rule, replacement.new)
}
*rule = replaceReversedLikes(*rule)
if originalRule != *rule {
color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
color.Green(" - new: %s", strings.TrimSpace(*rule))
fmt.Println()
}
}
// ---
if err := txDao.SaveCollection(collection); err != nil {
return err
}
}
return nil
}
func migrateUsers(app core.App, txDao *daos.Dao) error {
color.Blue(`[merging "_users" and "profiles"]:`)
profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
if err != nil {
return err
}
originalProfilesCollectionId := profilesCollection.Id
// change the profiles collection id to something else since we will be using
// it for the new users collection in order to avoid renaming the storage dir
_, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{_collections}}
SET id = '%s'
WHERE id = '%s';
`,
(originalProfilesCollectionId + "__old__"),
originalProfilesCollectionId,
)).Execute()
if idRenameErr != nil {
return idRenameErr
}
// refresh profiles collection
profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
if err != nil {
return err
}
usersSchema, _ := profilesCollection.Schema.Clone()
userIdField := usersSchema.GetFieldByName("userId")
if userIdField != nil {
usersSchema.RemoveField(userIdField.Id)
}
usersCollection := &models.Collection{}
usersCollection.MarkAsNew()
usersCollection.Id = originalProfilesCollectionId
usersCollection.Name = "users"
usersCollection.Type = models.CollectionTypeAuth
usersCollection.Schema = *usersSchema
usersCollection.CreateRule = types.Pointer("")
if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
*profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
usersCollection.ListRule = profilesCollection.ListRule
}
if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
*profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
usersCollection.ViewRule = profilesCollection.ViewRule
}
if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
*profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
usersCollection.UpdateRule = profilesCollection.UpdateRule
}
if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
*profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
usersCollection.DeleteRule = profilesCollection.DeleteRule
}
// set auth options
settings := app.Settings()
authOptions := usersCollection.AuthOptions()
authOptions.ManageRule = nil
authOptions.AllowOAuth2Auth = true
authOptions.AllowUsernameAuth = false
authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
// twitter currently is the only provider that doesn't return an email
authOptions.RequireEmail = !settings.TwitterAuth.Enabled
usersCollection.SetOptions(authOptions)
if err := txDao.SaveCollection(usersCollection); err != nil {
return err
}
// copy the original users
_, usersErr := txDao.DB().NewQuery(`
INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
FROM {{_users}};
`).Execute()
if usersErr != nil {
return usersErr
}
// generate the profile fields copy statements
sets := []string{"id = p.id"}
for _, f := range usersSchema.Fields() {
sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
}
// copy profile fields
_, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
UPDATE {{users}} as u
SET %s
FROM {{profiles}} as p
WHERE u.id = p.userId;
`, strings.Join(sets, ", "))).Execute()
if copyProfileErr != nil {
return copyProfileErr
}
profileRecords, err := txDao.FindRecordsByExpr("profiles")
if err != nil {
return err
}
// update all profiles and users fields to point to the new users collection
collections := []*models.Collection{}
if err := txDao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, collection := range collections {
var hasChanges bool
for _, f := range collection.Schema.Fields() {
f.InitOptions()
if f.Type == schema.FieldTypeUser {
if collection.Name == "profiles" && f.Name == "userId" {
continue
}
hasChanges = true
// change the user field to a relation field
options, _ := f.Options.(*schema.UserOptions)
f.Type = schema.FieldTypeRelation
f.Options = &schema.RelationOptions{
CollectionId: usersCollection.Id,
MaxSelect: &options.MaxSelect,
CascadeDelete: options.CascadeDelete,
}
for _, p := range profileRecords {
pId := p.Id
pUserId := p.GetString("userId")
// replace all user record id references with the profile id
_, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
UPDATE %s
SET [[%s]] = REPLACE([[%s]], '%s', '%s')
WHERE [[%s]] LIKE ('%%%s%%');
`, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
if replaceErr != nil {
return replaceErr
}
}
}
}
if hasChanges {
if err := txDao.Save(collection); err != nil {
return err
}
}
}
if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
return err
}
// drop _users table
if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
return err
}
// drop profiles table
if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
return err
}
// delete profiles collection
if err := txDao.Delete(profilesCollection); err != nil {
return err
}
color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
fmt.Println()
return nil
}
func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
_, alterErr := txDao.DB().NewQuery(`
-- crate new externalAuths table
CREATE TABLE {{_newExternalAuths}} (
[[id]] TEXT PRIMARY KEY,
[[collectionId]] TEXT NOT NULL,
[[recordId]] TEXT NOT NULL,
[[provider]] TEXT NOT NULL,
[[providerId]] TEXT NOT NULL,
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL,
---
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
);
-- copy all data from the old table to the new one
INSERT INTO {{_newExternalAuths}}
SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
FROM {{_externalAuths}} auth
INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
-- drop old table
DROP TABLE {{_externalAuths}};
-- rename new table
ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
-- create named indexes
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
`).Execute()
return alterErr
}
func resetMigrationsTable(txDao *daos.Dao) error {
// reset the migration state to the new init
_, err := txDao.DB().Delete("_migrations", dbx.HashExp{
"file": "1661586591_add_externalAuths_table.go",
}).Execute()
return err
}
var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
func replaceReversedLikes(rule string) string {
parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
for _, p := range parts {
if len(p) != 4 {
continue
}
newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
rule = strings.ReplaceAll(rule, p[0], newPart)
}
return rule
}
+146 -91
View File
@@ -4,6 +4,9 @@
package core
import (
"context"
"log/slog"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/settings"
@@ -46,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
@@ -53,28 +59,38 @@ 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
// IsDev returns whether the app is in dev mode.
IsDev() 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
// NewMailClient creates and returns a configured app mail client.
NewMailClient() mailer.Mailer
// NewFilesystem creates and returns a configured filesystem.System instance.
// NewFilesystem creates and returns a configured filesystem.System instance
// for managing regular app files (eg. collection uploads).
//
// NB! Make sure to call `Close()` on the returned result
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
NewFilesystem() (*filesystem.System, error)
// NewBackupsFilesystem creates and returns a configured filesystem.System instance
// for managing app backups.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
NewBackupsFilesystem() (*filesystem.System, error)
// RefreshSettings reinitializes and reloads the stored application settings.
RefreshSettings() error
@@ -92,25 +108,50 @@ type App interface {
// (eg. closing db connections).
ResetBootstrapState() error
// CreateBackup creates a new backup of the current app pb_data directory.
//
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
//
// Please refer to the godoc of the specific core.App implementation
// for details on the backup procedures.
CreateBackup(ctx context.Context, name string) error
// RestoreBackup restores the backup with the specified name and restarts
// the current running application process.
//
// The safely perform the restore it is recommended to have free disk space
// for at least 2x the size of the restored pb_data backup.
//
// Please refer to the godoc of the specific core.App implementation
// for details on the restore procedures.
//
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
RestoreBackup(ctx context.Context, name string) error
// Restart restarts the current running application process.
//
// Currently it is relying on execve so it is supported only on UNIX based systems.
Restart() error
// ---------------------------------------------------------------
// App event hooks
// ---------------------------------------------------------------
// OnBeforeBootstrap hook is triggered before initializing the base
// OnBeforeBootstrap hook is triggered before initializing the main
// application resources (eg. before db open and initial settings load).
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
// OnAfterBootstrap hook is triggered after initializing the base
// OnAfterBootstrap hook is triggered after initializing the main
// application resources (eg. after db open and initial settings load).
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
// OnBeforeServe hook is triggered before serving the internal router (echo),
// allowing you to adjust its options and attach new routes.
// allowing you to adjust its options and attach new routes or middlewares.
OnBeforeServe() *hook.Hook[*ServeEvent]
// OnBeforeApiError hook is triggered right before sending an error API
// response to the client, allowing you to further modify the error data
// or to return a completely different API response (using [hook.StopPropagation]).
// or to return a completely different API response.
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
// OnAfterApiError hook is triggered right after sending an error API
@@ -118,12 +159,16 @@ type App interface {
// It could be used to log the final API error in external services.
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
// OnTerminate hook is triggered when the app is in the process
// of being terminated (eg. on SIGTERM signal).
OnTerminate() *hook.Hook[*TerminateEvent]
// ---------------------------------------------------------------
// Dao event hooks
// ---------------------------------------------------------------
// OnModelBeforeCreate hook is triggered before inserting a new
// entry in the DB, allowing you to modify or validate the stored data.
// model in the DB, allowing you to modify or validate the stored data.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -131,7 +176,7 @@ type App interface {
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterCreate hook is triggered after successfully
// inserting a new entry in the DB.
// inserting a new model in the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -139,7 +184,7 @@ type App interface {
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeUpdate hook is triggered before updating existing
// entry in the DB, allowing you to modify or validate the stored data.
// model in the DB, allowing you to modify or validate the stored data.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -147,7 +192,7 @@ type App interface {
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterUpdate hook is triggered after successfully updating
// existing entry in the DB.
// existing model in the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -155,15 +200,15 @@ type App interface {
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeDelete hook is triggered before deleting an
// existing entry from the DB.
// existing model from the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterDelete is triggered after successfully deleting an
// existing entry from the DB.
// OnModelAfterDelete hook is triggered after successfully deleting an
// existing model from the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -174,22 +219,18 @@ type App interface {
// Mailer event hooks
// ---------------------------------------------------------------
// OnMailerBeforeAdminResetPasswordSend hook is triggered right before
// sending a password reset email to an admin.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// OnMailerBeforeAdminResetPasswordSend hook is triggered right
// before sending a password reset email to an admin, allowing you
// to inspect and customize the email message that is being sent.
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerAfterAdminResetPasswordSend hook is triggered after
// admin password reset email was successfully sent.
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
// sending a password reset email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// OnMailerBeforeRecordResetPasswordSend hook is triggered right
// before sending a password reset email to an auth record, allowing
// you to inspect and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -204,11 +245,9 @@ type App interface {
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeRecordVerificationSend hook is triggered right before
// sending a verification email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// OnMailerBeforeRecordVerificationSend hook is triggered right
// before sending a verification email to an auth record, allowing
// you to inspect and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -224,10 +263,8 @@ type App interface {
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
// sending a confirmation new address email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// sending a confirmation new address email to an auth record, allowing
// you to inspect and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -254,14 +291,14 @@ type App interface {
// SSE client connection.
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
// OnRealtimeBeforeMessage hook is triggered right before sending
// OnRealtimeBeforeMessageSend hook is triggered right before sending
// an SSE message to a client.
//
// Returning [hook.StopPropagation] will prevent sending the message.
// Returning any other non-nil error will close the realtime connection.
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeMessage hook is triggered right after sending
// OnRealtimeAfterMessageSend hook is triggered right after sending
// an SSE message to a client.
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
@@ -289,8 +326,7 @@ type App interface {
// Settings update request (after request data load and before settings persistence).
//
// Could be used to additionally validate the request data or
// implement completely different persistence behavior
// (returning [hook.StopPropagation]).
// implement completely different persistence behavior.
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
// OnSettingsAfterUpdateRequest hook is triggered after each
@@ -307,6 +343,25 @@ type App interface {
// returning it to the client.
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
// OnFileBeforeTokenRequest hook is triggered before each file
// token API request.
//
// If no token or model was submitted, e.Model and e.Token will be empty,
// allowing you to implement your own custom model file auth implementation.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
// OnFileAfterTokenRequest hook is triggered after each
// successful file token API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
// ---------------------------------------------------------------
// Admin API event hooks
// ---------------------------------------------------------------
@@ -325,7 +380,7 @@ type App interface {
// Admin create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
// OnAdminAfterCreateRequest hook is triggered after each
@@ -336,7 +391,7 @@ type App interface {
// Admin update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
// OnAdminAfterUpdateRequest hook is triggered after each
@@ -347,7 +402,7 @@ type App interface {
// Admin delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
// completely different delete behavior.
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
// OnAdminAfterDeleteRequest hook is triggered after each
@@ -376,7 +431,7 @@ type App interface {
// auth refresh API request (right before generating a new auth token).
//
// Could be used to additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
// completely different auth refresh behavior.
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
// OnAdminAfterAuthRefreshRequest hook is triggered after each
@@ -387,7 +442,7 @@ type App interface {
// request password reset API request (after request data load and before sending the reset email).
//
// Could be used to additionally validate the request data or implement
// completely different password reset behavior (returning [hook.StopPropagation]).
// completely different password reset behavior.
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
@@ -398,7 +453,7 @@ type App interface {
// confirm password reset API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
@@ -424,7 +479,7 @@ type App interface {
// auth with password API request (after request data load and before password validation).
//
// Could be used to implement for example a custom password validation
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
// or to locate a different Record model (by reassigning [RecordAuthWithPasswordEvent.Record]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -442,11 +497,11 @@ type App interface {
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
//
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
// If the [RecordAuthWithOAuth2Event.Record] is not set, then the OAuth2
// request will try to create a new auth Record.
//
// To assign or link a different existing record model you can
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
// change the [RecordAuthWithOAuth2Event.Record] field.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -465,7 +520,7 @@ type App interface {
// auth refresh API request (right before generating a new auth token).
//
// Could be used to additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
// completely different auth refresh behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -480,11 +535,39 @@ type App interface {
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
//
// Could be used to validate or modify the response before returning it to the client.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API record external auth unlink request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
// request password reset API request (after request data load and before sending the reset email).
//
// Could be used to additionally validate the request data or implement
// completely different password reset behavior (returning [hook.StopPropagation]).
// completely different password reset behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -503,7 +586,7 @@ type App interface {
// confirm password reset API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -522,7 +605,7 @@ type App interface {
// request verification API request (after request data load and before sending the verification email).
//
// Could be used to additionally validate the loaded request data or implement
// completely different verification behavior (returning [hook.StopPropagation]).
// completely different verification behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -541,7 +624,7 @@ type App interface {
// confirm verification API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -560,7 +643,7 @@ type App interface {
// (after request data load and before sending the email link to confirm the change).
//
// Could be used to additionally validate the request data or implement
// completely different request email change behavior (returning [hook.StopPropagation]).
// completely different request email change behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -579,7 +662,7 @@ type App interface {
// confirm email change API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -594,34 +677,6 @@ type App interface {
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
//
// Could be used to validate or modify the response before returning it to the client.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API record external auth unlink request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record CRUD API event hooks
// ---------------------------------------------------------------
@@ -648,7 +703,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -667,7 +722,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -686,7 +741,7 @@ type App interface {
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
// completely different delete behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -719,7 +774,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
// OnCollectionAfterCreateRequest hook is triggered after each
@@ -730,7 +785,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
// OnCollectionAfterUpdateRequest hook is triggered after each
@@ -741,7 +796,7 @@ type App interface {
// Collection delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
// completely different delete behavior.
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionAfterDeleteRequest hook is triggered after each
@@ -752,7 +807,7 @@ type App interface {
// collections import request (after request data load and before the actual import).
//
// Could be used to additionally validate the imported collections or
// to implement completely different import behavior (returning [hook.StopPropagation]).
// to implement completely different import behavior.
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
// OnCollectionsAfterImportRequest hook is triggered after each
+294 -59
View File
@@ -5,8 +5,11 @@ import (
"database/sql"
"errors"
"log"
"log/slog"
"os"
"path/filepath"
"runtime"
"syscall"
"time"
"github.com/fatih/color"
@@ -16,25 +19,35 @@ 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 (
DefaultDataMaxOpenConns int = 100
DefaultDataMaxOpenConns int = 120
DefaultDataMaxIdleConns int = 20
DefaultLogsMaxOpenConns int = 10
DefaultLogsMaxIdleConns int = 2
LocalStorageDirName string = "storage"
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
)
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
isDev bool
dataDir string
encryptionEnv string
dataMaxOpenConns int
@@ -43,11 +56,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]
@@ -55,6 +69,7 @@ type BaseApp struct {
onBeforeServe *hook.Hook[*ServeEvent]
onBeforeApiError *hook.Hook[*ApiErrorEvent]
onAfterApiError *hook.Hook[*ApiErrorEvent]
onTerminate *hook.Hook[*TerminateEvent]
// dao event hooks
onModelBeforeCreate *hook.Hook[*ModelEvent]
@@ -88,7 +103,9 @@ type BaseApp struct {
onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent]
// file api event hooks
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent]
onFileAfterTokenRequest *hook.Hook[*FileTokenEvent]
// admin api event hooks
onAdminsListRequest *hook.Hook[*AdminsListEvent]
@@ -158,9 +175,9 @@ type BaseApp struct {
// BaseAppConfig defines a BaseApp configuration option
type BaseAppConfig struct {
IsDev bool
DataDir string
EncryptionEnv string
IsDebug bool
DataMaxOpenConns int // default to 500
DataMaxIdleConns int // default 20
LogsMaxOpenConns int // default to 100
@@ -171,16 +188,16 @@ type BaseAppConfig struct {
// configured with the provided arguments.
//
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config *BaseAppConfig) *BaseApp {
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
isDev: config.IsDev,
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(),
@@ -190,6 +207,7 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
onBeforeServe: &hook.Hook[*ServeEvent]{},
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
onTerminate: &hook.Hook[*TerminateEvent]{},
// dao event hooks
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
@@ -223,7 +241,9 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{},
// file API event hooks
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{},
onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{},
// admin API event hooks
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
@@ -302,6 +322,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.).
//
@@ -331,14 +362,17 @@ 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()
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
}
// cleanup the pb_data temp directory (if any)
os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
return nil
return app.OnAfterBootstrap().Trigger(event)
}
// ResetBootstrapState takes care for releasing initialized app resources
@@ -364,7 +398,6 @@ func (app *BaseApp) ResetBootstrapState() error {
app.dao = nil
app.logsDao = nil
app.settings = nil
return nil
}
@@ -428,10 +461,11 @@ 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
// IsDev returns whether the app is in dev mode.
//
// When enabled logs, executed sql statements, etc. are printed to the stderr.
func (app *BaseApp) IsDev() bool {
return app.isDev
}
// Settings returns the loaded app settings.
@@ -439,9 +473,15 @@ 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.
@@ -460,6 +500,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,
}
}
@@ -467,12 +508,13 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
}
// NewFilesystem creates a new local or S3 filesystem instance
// for managing regular app files (eg. collection uploads)
// based on the current app settings.
//
// NB! Make sure to call `Close()` on the returned result
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
if app.settings.S3.Enabled {
if app.settings != nil && app.settings.S3.Enabled {
return filesystem.NewS3(
app.settings.S3.Bucket,
app.settings.S3.Region,
@@ -484,7 +526,54 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), "storage"))
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
// NewFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
if app.settings != nil && app.settings.Backups.S3.Enabled {
return filesystem.NewS3(
app.settings.Backups.S3.Bucket,
app.settings.Backups.S3.Region,
app.settings.Backups.S3.Endpoint,
app.settings.Backups.S3.AccessKey,
app.settings.Backups.S3.Secret,
app.settings.Backups.S3.ForcePathStyle,
)
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalBackupsDirName))
}
// Restart restarts (aka. replaces) the current running application process.
//
// NB! It relies on execve which is supported only on UNIX based systems.
func (app *BaseApp) Restart() error {
if runtime.GOOS == "windows" {
return errors.New("restart is not supported on windows")
}
execPath, err := os.Executable()
if err != nil {
return err
}
return app.OnTerminate().Trigger(&TerminateEvent{
App: app,
IsRestart: true,
}, func(e *TerminateEvent) error {
e.App.ResetBootstrapState()
// attempt to restart the bootstrap process in case execve returns an error for some reason
defer e.App.Bootstrap()
return syscall.Exec(execPath, os.Args, os.Environ())
})
}
// RefreshSettings reinitializes and reloads the stored application settings.
@@ -496,7 +585,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
}
@@ -510,6 +599,13 @@ func (app *BaseApp) RefreshSettings() error {
return err
}
// reload handler level (if initialized and not in dev mode)
if !app.IsDev() && app.Logger() != nil {
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
h.SetLevel(slog.Level(app.settings.Logs.MinLevel))
}
}
return nil
}
@@ -537,6 +633,10 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
return app.onAfterApiError
}
func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
return app.onTerminate
}
// -------------------------------------------------------------------
// Dao event hooks
// -------------------------------------------------------------------
@@ -653,6 +753,14 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}
func (app *BaseApp) OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
return hook.NewTaggedHook(app.onFileBeforeTokenRequest, tags...)
}
func (app *BaseApp) OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
return hook.NewTaggedHook(app.onFileAfterTokenRequest, tags...)
}
// -------------------------------------------------------------------
// Admin API event hooks
// -------------------------------------------------------------------
@@ -917,7 +1025,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 {
@@ -925,7 +1033,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)
@@ -948,7 +1056,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 {
@@ -956,17 +1064,16 @@ 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() {
if app.IsDev() {
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
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.QueryLogFunc = nonconcurrentDB.QueryLogFunc
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
}
@@ -978,37 +1085,58 @@ func (app *BaseApp) initDataDB() error {
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m})
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeCreate().Trigger(e, func(e *ModelEvent) error {
return action()
})
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelAfterCreate().Trigger(e)
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m})
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeUpdate().Trigger(e, func(e *ModelEvent) error {
return action()
})
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelAfterUpdate().Trigger(e)
}
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m})
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeDelete().Trigger(e, func(e *ModelEvent) error {
return action()
})
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelAfterDelete().Trigger(e)
}
return dao
@@ -1037,18 +1165,125 @@ 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()),
)
}
})
}
return nil
})
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)
// Apply the min level only if it is not in develop
// to allow printing the logs to the console.
//
// DB logs are still filtered but the checks for the min level are done
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
var minLevel slog.Level
if app.IsDev() {
minLevel = -9999
} else if app.Settings() != nil {
minLevel = slog.Level(app.Settings().Logs.MinLevel)
}
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: minLevel,
BatchSize: 200,
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
if app.IsDev() {
printLog(log)
// manually check the log level and skip if necessary
if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
return false
}
}
ticker.Reset(duration)
return app.Settings().Logs.MaxDays > 0
},
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
})
return nil
}
+361
View File
@@ -0,0 +1,361 @@
package core
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"sort"
"time"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"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.
// If backup with the same name exists, the new backup file will replace it.
//
// The backup is executed within a transaction, meaning that new writes
// will be temporary "blocked" until the backup file is generated.
//
// To safely perform the backup, it is recommended to have free disk space
// for at least 2x the size of the pb_data directory.
//
// By default backups are stored in pb_data/backups
// (the backups directory itself is excluded from the generated backup).
//
// When using S3 storage for the uploaded collection files, you have to
// take care manually to backup those since they are not part of the pb_data.
//
// 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.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
if name == "" {
name = app.generateBackupName("pb_backup_")
}
app.Store().Set(StoreKeyActiveBackup, name)
defer app.Store().Remove(StoreKeyActiveBackup)
// 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(localTempDir, "pb_backup_"+security.PseudorandomString(4))
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// @todo consider experimenting with temp switching the readonly pragma after the db interface change
return archive.Create(app.DataDir(), tempPath, exclude...)
})
if createErr != nil {
return createErr
}
defer os.Remove(tempPath)
// Persist the backup in the backups filesystem.
// ---
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(ctx)
file, err := filesystem.NewFileFromPath(tempPath)
if err != nil {
return err
}
file.OriginalName = name
file.Name = file.OriginalName
if err := fsys.UploadFile(file, file.Name); err != nil {
return err
}
return nil
}
// RestoreBackup restores the backup with the specified name and restarts
// the current running application process.
//
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
//
// To safely perform the restore it is recommended to have free disk space
// for at least 2x the size of the restored pb_data backup.
//
// The performed steps are:
//
// 1. Download the backup with the specified name in a temp location
// (this is in case of S3; otherwise it creates a temp copy of the zip)
//
// 2. Extract the backup in a temp directory inside the app "pb_data"
// (eg. "pb_data/.pb_temp_to_delete/pb_restore").
//
// 3. Move the current app "pb_data" content (excluding the local backups and the special temp dir)
// under another temp sub dir that will be deleted on the next app start up
// (eg. "pb_data/.pb_temp_to_delete/old_pb_data").
// This is because on some environments it may not be allowed
// to delete the currently open "pb_data" files.
//
// 4. Move the extracted dir content to the app "pb_data".
//
// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
//
// If a failure occure during the restore process the dir changes are reverted.
// If for whatever reason the revert is not possible, it panics.
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
if runtime.GOOS == "windows" {
return errors.New("restore is not supported on windows")
}
if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
app.Store().Set(StoreKeyActiveBackup, name)
defer app.Store().Remove(StoreKeyActiveBackup)
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(ctx)
// fetch the backup file in a temp location
br, err := fsys.GetFile(name)
if err != nil {
return err
}
defer br.Close()
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(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
}
defer os.Remove(tempZip.Name())
if _, err := io.Copy(tempZip, br); err != nil {
return err
}
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
defer os.RemoveAll(extractedDataDir)
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
return err
}
// ensure that a database file exists
extractedDB := filepath.Join(extractedDataDir, "data.db")
if _, err := os.Stat(extractedDB); err != nil {
return fmt.Errorf("data.db file is missing or invalid: %w", err)
}
// remove the extracted zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
if err := os.Remove(tempZip.Name()); err != nil {
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
exclude := []string{LocalBackupsDirName, LocalTempDirName}
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
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)
}
// move the extracted archive content to the app's pb_data
if err := osutils.MoveDirContent(extractedDataDir, app.DataDir(), exclude...); err != nil {
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
}
revertDataDirChanges := func() error {
if err := osutils.MoveDirContent(app.DataDir(), extractedDataDir, exclude...); err != nil {
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
}
if err := osutils.MoveDirContent(oldTempDataDir, app.DataDir(), exclude...); err != nil {
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
}
return nil
}
// restart the app
if err := app.Restart(); err != nil {
if revertErr := revertDataDirChanges(); revertErr != nil {
panic(revertErr)
}
return fmt.Errorf("failed to restart the app process: %w", err)
}
return nil
}
// initAutobackupHooks registers the autobackup app serve hooks.
func (app *BaseApp) initAutobackupHooks() error {
c := cron.New()
isServe := false
loadJob := func() {
c.Stop()
rawSchedule := app.Settings().Backups.Cron
if rawSchedule == "" || !isServe || !app.IsBootstrapped() {
return
}
c.Add("@autobackup", rawSchedule, func() {
const autoPrefix = "@auto_pb_backup_"
name := app.generateBackupName(autoPrefix)
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
if maxKeep == 0 {
return // no explicit limit
}
fsys, err := app.NewBackupsFilesystem()
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.Logger().Debug(
"[Backup cron] Failed to list autogenerated backups",
slog.String("error", err.Error()),
)
return
}
if maxKeep >= len(files) {
return // nothing to remove
}
// sort desc
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime.After(files[j].ModTime)
})
// keep only the most recent n auto backup files
toRemove := files[maxKeep:]
for _, f := range toRemove {
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()),
)
}
}
})
// restart the ticker
c.Start()
}
// load on app serve
app.OnBeforeServe().Add(func(e *ServeEvent) error {
isServe = true
loadJob()
return nil
})
// stop the ticker on app termination
app.OnTerminate().Add(func(e *TerminateEvent) error {
c.Stop()
return nil
})
// reload on app settings change
app.OnModelAfterUpdate((&models.Param{}).TableName()).Add(func(e *ModelEvent) error {
p := e.Model.(*models.Param)
if p == nil || p.Key != models.ParamAppSettings {
return nil
}
loadJob()
return nil
})
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"),
)
}
+164
View File
@@ -0,0 +1,164 @@
package core_test
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/list"
)
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.Store().Set(core.StoreKeyActiveBackup, "")
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
t.Fatal("Expected pending error, got nil")
}
app.Store().Remove(core.StoreKeyActiveBackup)
// create with auto generated name
if err := app.CreateBackup(context.Background(), ""); err != nil {
t.Fatal("Failed to create a backup with autogenerated name")
}
// create with custom name
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
t.Fatal("Failed to create a backup with custom name")
}
// create new with the same name (aka. replace)
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
t.Fatal("Failed to create and replace a backup with the same name")
}
backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
entries, err := os.ReadDir(backupsDir)
if err != nil {
t.Fatal(err)
}
expectedFiles := []string{
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
"custom",
"custom.attrs",
}
if len(entries) != len(expectedFiles) {
names := getEntryNames(entries)
t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
}
for i, entry := range entries {
if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
}
if strings.HasSuffix(entry.Name(), ".attrs") {
continue
}
path := filepath.Join(backupsDir, entry.Name())
if err := verifyBackupContent(app, path); err != nil {
t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
}
}
}
func TestRestoreBackup(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create a initial test backup to ensure that there are at least 1
// backup file and that the generated zip doesn't contain the backups dir
if err := app.CreateBackup(context.Background(), "initial"); err != nil {
t.Fatal("Failed to create test initial backup")
}
// create test backup
if err := app.CreateBackup(context.Background(), "test"); err != nil {
t.Fatal("Failed to create test backup")
}
// test pending error
app.Store().Set(core.StoreKeyActiveBackup, "")
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
t.Fatal("Expected pending error, got nil")
}
app.Store().Remove(core.StoreKeyActiveBackup)
// missing backup
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
t.Fatal("Expected missing error, got nil")
}
}
// -------------------------------------------------------------------
func verifyBackupContent(app core.App, path string) error {
dir, err := os.MkdirTemp("", "backup_test")
if err != nil {
return err
}
defer os.RemoveAll(dir)
if err := archive.Extract(path, dir); err != nil {
return err
}
expectedRootEntries := []string{
"storage",
"data.db",
"data.db-shm",
"data.db-wal",
"logs.db",
"logs.db-shm",
"logs.db-wal",
".gitignore",
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
if len(entries) != len(expectedRootEntries) {
names := getEntryNames(entries)
return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
}
for _, entry := range entries {
if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
return fmt.Errorf("Didn't expect %q entry", entry.Name())
}
}
return nil
}
func getEntryNames(entries []fs.DirEntry) []string {
names := make([]string, len(entries))
for i, entry := range entries {
names[i] = entry.Name()
}
return names
}
+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,
+349 -35
View File
@@ -1,20 +1,30 @@
package core
import (
"fmt"
"log/slog"
"os"
"testing"
"time"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/migrate"
)
func TestNewBaseApp(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "test_env",
IsDebug: true,
IsDev: true,
})
if app.dataDir != testDataDir {
@@ -25,12 +35,12 @@ 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.isDev {
t.Fatalf("expected isDev true, got %v", app.isDev)
}
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 {
@@ -46,10 +56,9 @@ func TestBaseAppBootstrap(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
defer app.ResetBootstrapState()
@@ -57,7 +66,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 +114,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,20 +134,16 @@ 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) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
IsDev: true,
})
defer app.ResetBootstrapState()
@@ -163,16 +175,20 @@ 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.isDev != app.IsDev() {
t.Fatalf("Expected app.IsDev %v, got %v", app.IsDev(), app.isDev)
}
if app.settings != app.Settings() {
t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
}
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() {
@@ -185,14 +201,11 @@ func TestBaseAppGetters(t *testing.T) {
}
func TestBaseAppNewMailClient(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
client1 := app.NewMailClient()
if val, ok := client1.(*mailer.Sendmail); !ok {
@@ -208,14 +221,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
}
func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
// local
local, localErr := app.NewFilesystem()
@@ -236,3 +246,307 @@ func TestBaseAppNewFilesystem(t *testing.T) {
t.Fatalf("Expected nil s3 filesystem, got %v", s3)
}
}
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
// local
local, localErr := app.NewBackupsFilesystem()
if localErr != nil {
t.Fatal(localErr)
}
if local == nil {
t.Fatal("Expected local backups filesystem instance, got nil")
}
// misconfigured s3
app.Settings().Backups.S3.Enabled = true
s3, s3Err := app.NewBackupsFilesystem()
if s3Err == nil {
t.Fatal("Expected S3 error, got nil")
}
if s3 != nil {
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
}
}
func TestBaseAppLoggerWrites(t *testing.T) {
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
threshold := 200
totalLogs := func(app App, t *testing.T) int {
var total int
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
if err != nil {
t.Fatalf("Failed to fetch total logs: %v", err)
}
return total
}
// 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 TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
if !ok {
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
}
scenarios := []struct {
name string
isDev bool
level int
// level->enabled map
expectations map[int]bool
}{
{
"dev mode",
true,
4,
map[int]bool{
3: true,
4: true,
5: true,
},
},
{
"nondev mode",
false,
4,
map[int]bool{
3: false,
4: true,
5: true,
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app.isDev = s.isDev
app.Settings().Logs.MinLevel = s.level
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
if err := app.RefreshSettings(); err != nil {
t.Fatalf("Failed to refresh app settings: %v", err)
}
for level, enabled := range s.expectations {
if v := handler.Enabled(nil, slog.Level(level)); v != enabled {
t.Fatalf("Expected level %d Enabled() to be %v, got %v", level, enabled, v)
}
}
})
}
}
func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
app, cleanup, err := initTestBaseApp()
if err != nil {
t.Fatal(err)
}
defer cleanup()
testLogLevel := 4
app.Settings().Logs.MinLevel = testLogLevel
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
isDev bool
levels []int
printedLevels []int
persistedLevels []int
}{
{
"dev mode",
true,
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
[]int{testLogLevel, testLogLevel + 1},
},
{
"nondev mode",
false,
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
[]int{},
[]int{testLogLevel, testLogLevel + 1},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
var printedLevels []int
var persistedLevels []int
app.isDev = s.isDev
// trigger slog handler min level refresh
if err := app.RefreshSettings(); err != nil {
t.Fatal(err)
}
// track printed logs
originalPrintLog := printLog
defer func() {
printLog = originalPrintLog
}()
printLog = func(log *logger.Log) {
printedLevels = append(printedLevels, int(log.Level))
}
// track persisted logs
app.LogsDao().AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
l, ok := m.(*models.Log)
if ok {
persistedLevels = append(persistedLevels, l.Level)
}
return nil
}
// write and persist logs
for _, l := range s.levels {
app.Logger().Log(nil, slog.Level(l), "test")
}
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
if !ok {
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
}
if err := handler.WriteAll(nil); err != nil {
t.Fatalf("Failed to write all logs: %v", err)
}
// check persisted log levels
if len(s.persistedLevels) != len(persistedLevels) {
t.Fatalf("Expected persisted levels \n%v\ngot\n%v", s.persistedLevels, persistedLevels)
}
for _, l := range persistedLevels {
if !list.ExistInSlice(l, s.persistedLevels) {
t.Fatalf("Missing expected persisted level %v in %v", l, persistedLevels)
}
}
// check printed log levels
if len(s.printedLevels) != len(printedLevels) {
t.Fatalf("Expected printed levels \n%v\ngot\n%v", s.printedLevels, printedLevels)
}
for _, l := range printedLevels {
if !list.ExistInSlice(l, s.printedLevels) {
t.Fatalf("Missing expected printed level %v in %v", l, printedLevels)
}
}
})
}
}
// -------------------------------------------------------------------
// note: make sure to call `defer cleanup()` when the app is no longer needed.
func initTestBaseApp() (app *BaseApp, cleanup func(), err error) {
testDataDir, err := os.MkdirTemp("", "test_base_app")
if err != nil {
return nil, nil, err
}
cleanup = func() {
os.RemoveAll(testDataDir)
}
app = NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
initErr := func() error {
if err := app.Bootstrap(); err != nil {
return fmt.Errorf("bootstrap error: %w", err)
}
logsRunner, err := migrate.NewRunner(app.LogsDB(), logs.LogsMigrations)
if err != nil {
return fmt.Errorf("logsRunner error: %w", err)
}
if _, err := logsRunner.Up(); err != nil {
return fmt.Errorf("logsRunner migrations execution error: %w", err)
}
dataRunner, err := migrate.NewRunner(app.DB(), migrations.AppMigrations)
if err != nil {
return fmt.Errorf("logsRunner error: %w", err)
}
if _, err := dataRunner.Up(); err != nil {
return fmt.Errorf("dataRunner migrations execution error: %w", err)
}
return nil
}()
if initErr != nil {
cleanup()
return nil, nil, initErr
}
return app, cleanup, nil
}
-20
View File
@@ -1,20 +0,0 @@
package core
import (
"github.com/pocketbase/dbx"
)
func initPragmas(db *dbx.DB) error {
// note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection
_, err := db.NewQuery(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = TRUE;
`).Execute()
return err
}
+36 -8
View File
@@ -3,18 +3,46 @@
package core
import (
"database/sql"
"github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
_ "github.com/mattn/go-sqlite3"
)
func connectDB(dbPath string) (*dbx.DB, error) {
db, err := dbx.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
func init() {
// Registers the sqlite3 driver with a ConnectHook so that we can
// initialize the default PRAGMAs.
//
// Note 1: we don't define the PRAGMA as part of the dsn string
// because not all pragmas are available.
//
// Note 2: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
sql.Register("pb_sqlite3",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
_, err := conn.Exec(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -16000;
`, nil)
if err := initPragmas(db); err != nil {
db.Close()
return err
},
},
)
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
}
func connectDB(dbPath string) (*dbx.DB, error) {
db, err := dbx.Open("pb_sqlite3", dbPath)
if err != nil {
return nil, err
}
+6 -6
View File
@@ -8,13 +8,13 @@ import (
)
func connectDB(dbPath string) (*dbx.DB, error) {
db, err := dbx.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
// Note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
if err := initPragmas(db); err != nil {
db.Close()
db, err := dbx.Open("sqlite", dbPath+pragmas)
if err != nil {
return nil, err
}
+58 -26
View File
@@ -1,19 +1,44 @@
package core
import (
"net/http"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/labstack/echo/v5"
"golang.org/x/crypto/acme/autocert"
)
var (
_ hook.Tagger = (*BaseModelEvent)(nil)
_ hook.Tagger = (*BaseCollectionEvent)(nil)
)
type BaseModelEvent struct {
Model models.Model
}
func (e *BaseModelEvent) Tags() []string {
if e.Model == nil {
return nil
}
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
return []string{r.Collection().Id, r.Collection().Name}
}
return []string{e.Model.TableName()}
}
type BaseCollectionEvent struct {
Collection *models.Collection
}
@@ -44,9 +69,16 @@ type BootstrapEvent struct {
App App
}
type TerminateEvent struct {
App App
IsRestart bool
}
type ServeEvent struct {
App App
Router *echo.Echo
App App
Router *echo.Echo
Server *http.Server
CertManager *autocert.Manager
}
type ApiErrorEvent struct {
@@ -58,23 +90,10 @@ type ApiErrorEvent struct {
// Model DAO events data
// -------------------------------------------------------------------
var _ hook.Tagger = (*ModelEvent)(nil)
type ModelEvent struct {
Dao *daos.Dao
Model models.Model
}
BaseModelEvent
func (e *ModelEvent) Tags() []string {
if e.Model == nil {
return nil
}
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
return []string{r.Collection().Id, r.Collection().Name}
}
return []string{e.Model.TableName()}
Dao *daos.Dao
}
// -------------------------------------------------------------------
@@ -104,6 +123,7 @@ type MailerAdminEvent struct {
type RealtimeConnectEvent struct {
HttpContext echo.Context
Client subscriptions.Client
IdleTimeout time.Duration
}
type RealtimeDisconnectEvent struct {
@@ -160,15 +180,17 @@ type RecordViewEvent struct {
type RecordCreateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
}
type RecordUpdateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
}
type RecordDeleteEvent struct {
@@ -203,9 +225,12 @@ type RecordAuthWithPasswordEvent struct {
type RecordAuthWithOAuth2Event struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
OAuth2User *auth.AuthUser
HttpContext echo.Context
ProviderName string
ProviderClient auth.Provider
Record *models.Record
OAuth2User *auth.AuthUser
IsNewRecord bool
}
type RecordAuthRefreshEvent struct {
@@ -374,6 +399,13 @@ type CollectionsImportEvent struct {
// File API events data
// -------------------------------------------------------------------
type FileTokenEvent struct {
BaseModelEvent
HttpContext echo.Context
Token string
}
type FileDownloadEvent struct {
BaseCollectionEvent
+67
View File
@@ -0,0 +1,67 @@
package core
import (
"fmt"
"log/slog"
"strings"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/spf13/cast"
)
var cachedColors = store.New[*color.Color](nil)
// getColor returns [color.Color] object and cache it (if not already).
func getColor(attrs ...color.Attribute) (c *color.Color) {
cacheKey := fmt.Sprint(attrs)
if c = cachedColors.Get(cacheKey); c == nil {
c = color.New(attrs...)
cachedColors.Set(cacheKey, c)
}
return
}
// printLog prints the provided log to the stderr.
// (note: defined as variable to overwriting in the tests)
var printLog = func(log *logger.Log) {
var str strings.Builder
switch log.Level {
case slog.LevelDebug:
str.WriteString(getColor(color.Bold, color.FgHiBlack).Sprint("DEBUG "))
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
case slog.LevelInfo:
str.WriteString(getColor(color.Bold, color.FgWhite).Sprint("INFO "))
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
case slog.LevelWarn:
str.WriteString(getColor(color.Bold, color.FgYellow).Sprint("WARN "))
str.WriteString(getColor(color.FgYellow).Sprint(log.Message))
case slog.LevelError:
str.WriteString(getColor(color.Bold, color.FgRed).Sprint("ERROR "))
str.WriteString(getColor(color.FgRed).Sprint(log.Message))
default:
str.WriteString(getColor(color.Bold, color.FgCyan).Sprintf("[%d] ", log.Level))
str.WriteString(getColor(color.FgCyan).Sprint(log.Message))
}
str.WriteString("\n")
if v, ok := log.Data["type"]; ok && cast.ToString(v) == "request" {
padding := 0
keys := []string{"error", "details"}
for _, k := range keys {
if v := log.Data[k]; v != nil {
str.WriteString(getColor(color.FgHiRed).Sprintf("%s└─ %v", strings.Repeat(" ", padding), v))
str.WriteString("\n")
padding += 3
}
}
} else if len(log.Data) > 0 {
str.WriteString(getColor(color.FgHiBlack).Sprintf("└─ %v", log.Data))
str.WriteString("\n")
}
fmt.Print(str.String())
}
+2 -2
View File
@@ -46,9 +46,9 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
return model, nil
}
// FindAdminByToken finds the admin associated with the provided JWT token.
// FindAdminByToken finds the admin associated with the provided JWT.
//
// Returns an error if the JWT token is invalid or expired.
// Returns an error if the JWT is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
// @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
+171 -177
View File
@@ -4,19 +4,14 @@
package daos
import (
"context"
"errors"
"strings"
"sync"
"fmt"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"golang.org/x/sync/semaphore"
)
const DefaultMaxFailRetries = 5
// New creates a new Dao instance with the provided db builder
// (for both async and sync db operations).
func New(db dbx.Builder) *Dao {
@@ -27,28 +22,36 @@ func New(db dbx.Builder) *Dao {
// async and sync db builders.
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
return &Dao{
concurrentDB: concurrentDB,
nonconcurrentDB: nonconcurrentDB,
concurrentDB: concurrentDB,
nonconcurrentDB: nonconcurrentDB,
MaxLockRetries: 8,
ModelQueryTimeout: 30 * time.Second,
}
}
// Dao handles various db operations.
// Think of Dao as a repository and service layer in one.
//
// You can think of Dao as a repository and service layer in one.
type Dao struct {
// in a transaction both refer to the same *dbx.TX instance
concurrentDB dbx.Builder
nonconcurrentDB dbx.Builder
// @todo delete after removing Block and Continue
sem *semaphore.Weighted
mux sync.RWMutex
// MaxLockRetries specifies the default max "database is locked" auto retry attempts.
MaxLockRetries int
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
AfterCreateFunc func(eventDao *Dao, m models.Model)
BeforeUpdateFunc func(eventDao *Dao, m models.Model) error
AfterUpdateFunc func(eventDao *Dao, m models.Model)
BeforeDeleteFunc func(eventDao *Dao, m models.Model) error
AfterDeleteFunc func(eventDao *Dao, m models.Model)
// ModelQueryTimeout is the default max duration of a running ModelQuery().
//
// This field has no effect if an explicit query context is already specified.
ModelQueryTimeout time.Duration
// write hooks
BeforeCreateFunc func(eventDao *Dao, m models.Model, action func() error) error
AfterCreateFunc func(eventDao *Dao, m models.Model) error
BeforeUpdateFunc func(eventDao *Dao, m models.Model, action func() error) error
AfterUpdateFunc func(eventDao *Dao, m models.Model) error
BeforeDeleteFunc func(eventDao *Dao, m models.Model, action func() error) error
AfterDeleteFunc func(eventDao *Dao, m models.Model) error
}
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
@@ -74,55 +77,39 @@ func (dao *Dao) NonconcurrentDB() dbx.Builder {
return dao.nonconcurrentDB
}
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
//
// Block acquires a lock and blocks all other go routines that uses
// the Dao instance until dao.Continue() is called, effectively making
// the concurrent requests to perform synchronous db operations.
//
// This method should be used only as a last resort and as a workaround
// for the SQLITE_BUSY error when mixing read&write in a transaction.
//
// Example:
//
// func someLongRunningTransaction() error {
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer cancel()
// if err := app.Dao().Block(ctx); err != nil {
// return err
// }
// defer app.Dao().Continue()
//
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
// // some long running read&write transaction...
// })
// }
func (dao *Dao) Block(ctx context.Context) error {
if dao.sem == nil {
dao.mux.Lock()
dao.sem = semaphore.NewWeighted(1)
dao.mux.Unlock()
}
// Clone returns a new Dao with the same configuration options as the current one.
func (dao *Dao) Clone() *Dao {
clone := *dao
return dao.sem.Acquire(ctx, 1)
return &clone
}
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
//
// Continue releases the previously acquired Block() lock.
func (dao *Dao) Continue() {
if dao.sem == nil {
return
}
// WithoutHooks returns a new Dao with the same configuration options
// as the current one, but without create/update/delete hooks.
func (dao *Dao) WithoutHooks() *Dao {
new := dao.Clone()
dao.sem.Release(1)
new.BeforeCreateFunc = nil
new.AfterCreateFunc = nil
new.BeforeUpdateFunc = nil
new.AfterUpdateFunc = nil
new.BeforeDeleteFunc = nil
new.AfterDeleteFunc = nil
return new
}
// ModelQuery creates a new query with preset Select and From fields
// based on the provided model argument.
// ModelQuery creates a new preconfigured select query with preset
// SELECT, FROM and other common fields based on the provided model.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
tableName := m.TableName()
return dao.DB().Select("{{" + tableName + "}}.*").From(tableName)
return dao.DB().
Select("{{" + tableName + "}}.*").
From(tableName).
WithBuildHook(func(query *dbx.Query) {
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries))
})
}
// FindById finds a single db record with the specified id and
@@ -148,6 +135,8 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
// ---
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
txDao := New(txOrDB)
txDao.MaxLockRetries = dao.MaxLockRetries
txDao.ModelQueryTimeout = dao.ModelQueryTimeout
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
@@ -163,56 +152,69 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
txDao := New(tx)
if dao.BeforeCreateFunc != nil {
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeCreateFunc(eventDao, m)
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
return dao.BeforeCreateFunc(eventDao, m, action)
}
}
if dao.BeforeUpdateFunc != nil {
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeUpdateFunc(eventDao, m)
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
return dao.BeforeUpdateFunc(eventDao, m, action)
}
}
if dao.BeforeDeleteFunc != nil {
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeDeleteFunc(eventDao, m)
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model, action func() error) error {
return dao.BeforeDeleteFunc(eventDao, m, action)
}
}
if dao.AfterCreateFunc != nil {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
return nil
}
}
if dao.AfterUpdateFunc != nil {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
return nil
}
}
if dao.AfterDeleteFunc != nil {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
return nil
}
}
return fn(txDao)
})
if txError == nil {
// execute after event calls on successful transaction
// (note: using the non-transaction dao to allow following queries in the after hooks)
for _, call := range afterCalls {
switch call.Action {
case "create":
dao.AfterCreateFunc(dao, call.Model)
case "update":
dao.AfterUpdateFunc(dao, call.Model)
case "delete":
dao.AfterDeleteFunc(dao, call.Model)
}
}
if txError != nil {
return txError
}
return txError
// execute after event calls on successful transaction
// (note: using the non-transaction dao to allow following queries in the after hooks)
var errs []error
for _, call := range afterCalls {
var err error
switch call.Action {
case "create":
err = dao.AfterCreateFunc(dao, call.Model)
case "update":
err = dao.AfterUpdateFunc(dao, call.Model)
case "delete":
err = dao.AfterDeleteFunc(dao, call.Model)
}
if err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("after transaction errors: %w", errors.Join(errs...))
}
return nil
}
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
@@ -224,36 +226,41 @@ func (dao *Dao) Delete(m models.Model) error {
return errors.New("ID is not set")
}
return dao.failRetry(func(retryDao *Dao) error {
if retryDao.BeforeDeleteFunc != nil {
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
return dao.lockRetry(func(retryDao *Dao) error {
action := func() error {
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
return err
}
if retryDao.AfterDeleteFunc != nil {
retryDao.AfterDeleteFunc(retryDao, m)
}
return nil
}
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
return err
if retryDao.BeforeDeleteFunc != nil {
return retryDao.BeforeDeleteFunc(retryDao, m, action)
}
if retryDao.AfterDeleteFunc != nil {
retryDao.AfterDeleteFunc(retryDao, m)
}
return nil
}, DefaultMaxFailRetries)
return action()
})
}
// Save upserts (update or create if primary key is not set) the provided model.
// Save persists the provided model in the database.
//
// If m.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a model for update you can use m.MarkAsNotNew().
func (dao *Dao) Save(m models.Model) error {
if m.IsNew() {
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.create(m)
}, DefaultMaxFailRetries)
})
}
return dao.failRetry(func(retryDao *Dao) error {
return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.update(m)
}, DefaultMaxFailRetries)
})
}
func (dao *Dao) update(m models.Model) error {
@@ -267,35 +274,35 @@ func (dao *Dao) update(m models.Model) error {
m.RefreshUpdated()
action := func() error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
_, err := dao.NonconcurrentDB().Update(
m.TableName(),
dataMap,
dbx.HashExp{"id": m.GetId()},
).Execute()
if err != nil {
return err
}
} else if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
return err
}
if dao.AfterUpdateFunc != nil {
return dao.AfterUpdateFunc(dao, m)
}
return nil
}
if dao.BeforeUpdateFunc != nil {
if err := dao.BeforeUpdateFunc(dao, m); err != nil {
return err
}
return dao.BeforeUpdateFunc(dao, m, action)
}
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
_, err := dao.NonconcurrentDB().Update(
m.TableName(),
dataMap,
dbx.HashExp{"id": m.GetId()},
).Execute()
if err != nil {
return err
}
} else {
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
return err
}
}
if dao.AfterUpdateFunc != nil {
dao.AfterUpdateFunc(dao, m)
}
return nil
return action()
}
func (dao *Dao) create(m models.Model) error {
@@ -315,64 +322,51 @@ func (dao *Dao) create(m models.Model) error {
m.RefreshUpdated()
}
action := func() error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
if _, ok := dataMap["id"]; !ok {
dataMap["id"] = m.GetId()
}
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
if err != nil {
return err
}
} else if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
return err
}
// clears the "new" model flag
m.MarkAsNotNew()
if dao.AfterCreateFunc != nil {
return dao.AfterCreateFunc(dao, m)
}
return nil
}
if dao.BeforeCreateFunc != nil {
if err := dao.BeforeCreateFunc(dao, m); err != nil {
return err
}
return dao.BeforeCreateFunc(dao, m, action)
}
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
if _, ok := dataMap["id"]; !ok {
dataMap["id"] = m.GetId()
}
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
if err != nil {
return err
}
} else {
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
return err
}
}
// clears the "new" model flag
m.MarkAsNotNew()
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(dao, m)
}
return nil
return action()
}
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
retryDao := dao
attempts := 1
Retry:
if attempts == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
retryDao.AfterCreateFunc = dao.AfterCreateFunc
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
}
return baseLockRetry(func(attempt int) error {
if attempt == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
retryDao.AfterCreateFunc = dao.AfterCreateFunc
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
}
// execute
err := op(retryDao)
if err != nil &&
attempts < maxRetries &&
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
attempts++
goto Retry
}
return err
return op(retryDao)
}, dao.MaxLockRetries)
}
+65
View File
@@ -0,0 +1,65 @@
package daos
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/pocketbase/dbx"
)
// default retries intervals (in ms)
var defaultRetryIntervals = []int{100, 250, 350, 500, 700, 1000}
func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
return func(q *dbx.Query, op func() error) error {
if q.Context() == nil {
cancelCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
cancel()
//nolint:staticcheck
q.WithContext(nil) // reset
}()
q.WithContext(cancelCtx)
}
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
}
}
func baseLockRetry(op func(attempt int) error, maxRetries int) error {
attempt := 1
Retry:
err := op(attempt)
if err != nil &&
attempt <= maxRetries &&
// we are checking the err message to handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(getDefaultRetryInterval(attempt))
attempt++
goto Retry
}
return err
}
func getDefaultRetryInterval(attempt int) time.Duration {
if attempt < 0 || attempt > len(defaultRetryIntervals)-1 {
return time.Duration(defaultRetryIntervals[len(defaultRetryIntervals)-1]) * time.Millisecond
}
return time.Duration(defaultRetryIntervals[attempt]) * time.Millisecond
}
+60
View File
@@ -0,0 +1,60 @@
package daos
import (
"errors"
"testing"
)
func TestGetDefaultRetryInterval(t *testing.T) {
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i)
}
if i := getDefaultRetryInterval(999); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i)
}
if i := getDefaultRetryInterval(3); i.Milliseconds() != 500 {
t.Fatalf("Expected 500ms, got %v", i)
}
}
func TestBaseLockRetry(t *testing.T) {
scenarios := []struct {
err error
failUntilAttempt int
expectedAttempts int
}{
{nil, 3, 1},
{errors.New("test"), 3, 1},
{errors.New("database is locked"), 3, 3},
}
for i, s := range scenarios {
lastAttempt := 0
err := baseLockRetry(func(attempt int) error {
lastAttempt = attempt
if attempt < s.failUntilAttempt {
return s.err
}
return nil
}, s.failUntilAttempt+2)
if lastAttempt != s.expectedAttempts {
t.Errorf("[%d] Expected lastAttempt to be %d, got %d", i, s.expectedAttempts, lastAttempt)
}
if s.failUntilAttempt == s.expectedAttempts && err != nil {
t.Errorf("[%d] Expected nil, got err %v", i, err)
continue
}
if s.failUntilAttempt != s.expectedAttempts && s.err != nil && err == nil {
t.Errorf("[%d] Expected error %q, got nil", i, s.err)
continue
}
}
}
+213 -30
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"errors"
"testing"
"time"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
@@ -39,6 +40,161 @@ func TestNewMultiDB(t *testing.T) {
}
}
func TestDaoClone(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
hookCalls := map[string]int{}
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
dao.MaxLockRetries = 1
dao.ModelQueryTimeout = 2
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeDeleteFunc"]++
return action()
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeUpdateFunc"]++
return action()
}
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeCreateFunc"]++
return action()
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterDeleteFunc"]++
return nil
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterUpdateFunc"]++
return nil
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterCreateFunc"]++
return nil
}
clone := dao.Clone()
clone.MaxLockRetries = 3
clone.ModelQueryTimeout = 4
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["NewAfterCreateFunc"]++
return nil
}
if dao.MaxLockRetries == clone.MaxLockRetries {
t.Fatal("Expected different MaxLockRetries")
}
if dao.ModelQueryTimeout == clone.ModelQueryTimeout {
t.Fatal("Expected different ModelQueryTimeout")
}
emptyAction := func() error { return nil }
// trigger hooks
dao.BeforeDeleteFunc(nil, nil, emptyAction)
dao.BeforeUpdateFunc(nil, nil, emptyAction)
dao.BeforeCreateFunc(nil, nil, emptyAction)
dao.AfterDeleteFunc(nil, nil)
dao.AfterUpdateFunc(nil, nil)
dao.AfterCreateFunc(nil, nil)
clone.BeforeDeleteFunc(nil, nil, emptyAction)
clone.BeforeUpdateFunc(nil, nil, emptyAction)
clone.BeforeCreateFunc(nil, nil, emptyAction)
clone.AfterDeleteFunc(nil, nil)
clone.AfterUpdateFunc(nil, nil)
clone.AfterCreateFunc(nil, nil)
expectations := []struct {
hook string
total int
}{
{"BeforeDeleteFunc", 2},
{"BeforeUpdateFunc", 2},
{"BeforeCreateFunc", 2},
{"AfterDeleteFunc", 2},
{"AfterUpdateFunc", 2},
{"AfterCreateFunc", 1},
{"NewAfterCreateFunc", 1},
}
for _, e := range expectations {
if hookCalls[e.hook] != e.total {
t.Errorf("Expected %s to be caleed %d", e.hook, e.total)
}
}
}
func TestDaoWithoutHooks(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
hookCalls := map[string]int{}
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
dao.MaxLockRetries = 1
dao.ModelQueryTimeout = 2
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeDeleteFunc"]++
return action()
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeUpdateFunc"]++
return action()
}
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeCreateFunc"]++
return action()
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterDeleteFunc"]++
return nil
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterUpdateFunc"]++
return nil
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterCreateFunc"]++
return nil
}
new := dao.WithoutHooks()
if new.MaxLockRetries != dao.MaxLockRetries {
t.Fatalf("Expected MaxLockRetries %d, got %d", new.Clone().MaxLockRetries, dao.MaxLockRetries)
}
if new.ModelQueryTimeout != dao.ModelQueryTimeout {
t.Fatalf("Expected ModelQueryTimeout %d, got %d", new.Clone().ModelQueryTimeout, dao.ModelQueryTimeout)
}
if new.BeforeDeleteFunc != nil {
t.Fatal("Expected BeforeDeleteFunc to be nil")
}
if new.BeforeUpdateFunc != nil {
t.Fatal("Expected BeforeUpdateFunc to be nil")
}
if new.BeforeCreateFunc != nil {
t.Fatal("Expected BeforeCreateFunc to be nil")
}
if new.AfterDeleteFunc != nil {
t.Fatal("Expected AfterDeleteFunc to be nil")
}
if new.AfterUpdateFunc != nil {
t.Fatal("Expected AfterUpdateFunc to be nil")
}
if new.AfterCreateFunc != nil {
t.Fatal("Expected AfterCreateFunc to be nil")
}
}
func TestDaoModelQuery(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -71,6 +227,24 @@ func TestDaoModelQuery(t *testing.T) {
}
}
func TestDaoModelQueryCancellation(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
dao := daos.New(testApp.DB())
m := &models.Admin{}
if err := dao.ModelQuery(m).One(m); err != nil {
t.Fatalf("Failed to execute control query: %v", err)
}
dao.ModelQueryTimeout = 0 * time.Millisecond
if err := dao.ModelQuery(m).One(m); err == nil {
t.Fatal("Expected to be cancelled, got nil")
}
}
func TestDaoFindById(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -316,12 +490,13 @@ func TestDaoRetryCreate(t *testing.T) {
retryBeforeCreateHookCalls := 0
retryAfterCreateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeCreateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryAfterCreateHookCalls++
return nil
}
model := &models.Admin{Email: "new@example.com"}
@@ -342,7 +517,7 @@ func TestDaoRetryCreate(t *testing.T) {
// with non-locking error
retryBeforeCreateHookCalls = 0
retryAfterCreateHookCalls = 0
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeCreateHookCalls++
return errors.New("non-locking error")
}
@@ -374,12 +549,13 @@ func TestDaoRetryUpdate(t *testing.T) {
retryBeforeUpdateHookCalls := 0
retryAfterUpdateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeUpdateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryAfterUpdateHookCalls++
return nil
}
if err := retryDao.Save(model); err != nil {
@@ -399,7 +575,7 @@ func TestDaoRetryUpdate(t *testing.T) {
// with non-locking error
retryBeforeUpdateHookCalls = 0
retryAfterUpdateHookCalls = 0
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeUpdateHookCalls++
return errors.New("non-locking error")
}
@@ -425,12 +601,13 @@ func TestDaoRetryDelete(t *testing.T) {
retryBeforeDeleteHookCalls := 0
retryAfterDeleteHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeDeleteHookCalls++
return errors.New("database is locked")
}
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryAfterDeleteHookCalls++
return nil
}
model, _ := retryDao.FindAdminByEmail("test@example.com")
@@ -451,7 +628,7 @@ func TestDaoRetryDelete(t *testing.T) {
// with non-locking error
retryBeforeDeleteHookCalls = 0
retryAfterDeleteHookCalls = 0
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeDeleteHookCalls++
return errors.New("non-locking error")
}
@@ -478,13 +655,13 @@ func TestDaoBeforeHooksError(t *testing.T) {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
return errors.New("before_create")
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
return errors.New("before_update")
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
return errors.New("before_delete")
}
@@ -523,27 +700,30 @@ func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeCreateFuncCalls++
return nil
return action()
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeUpdateFuncCalls++
return nil
return action()
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeDeleteFuncCalls++
return nil
return action()
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterCreateFuncCalls++
return nil
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterUpdateFuncCalls++
return nil
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
afterDeleteFuncCalls++
return nil
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
@@ -611,27 +791,30 @@ func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeCreateFuncCalls++
return nil
return action()
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeUpdateFuncCalls++
return nil
return action()
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeDeleteFuncCalls++
return nil
return action()
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterCreateFuncCalls++
return nil
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterUpdateFuncCalls++
return nil
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
afterDeleteFuncCalls++
return nil
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
+235 -28
View File
@@ -1,6 +1,8 @@
package daos
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -129,21 +131,39 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
return err
}
if total := len(result); total > 0 {
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total)
names := make([]string, 0, len(result))
for ref := range result {
names = append(names, ref.Name)
}
return fmt.Errorf("The collection %q has external relation field references (%s).", collection.Name, strings.Join(names, ", "))
}
return dao.RunInTransaction(func(txDao *Dao) error {
// delete the related records table
if err := txDao.DeleteTable(collection.Name); err != nil {
return err
// delete the related view or records table
if collection.IsView() {
if err := txDao.DeleteView(collection.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(collection.Name); err != nil {
return err
}
}
// trigger views resave to check for dependencies
if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil {
return fmt.Errorf("The collection has a view dependency - %w", err)
}
return txDao.Delete(collection)
})
}
// SaveCollection upserts the provided Collection model and updates
// SaveCollection persists the provided Collection model and updates
// its related records table schema.
//
// If collecction.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a collection for update you can use collecction.MarkAsNotNew().
func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection
@@ -157,20 +177,41 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
}
}
return dao.RunInTransaction(func(txDao *Dao) error {
txErr := dao.RunInTransaction(func(txDao *Dao) error {
// set default collection type
if collection.Type == "" {
collection.Type = models.CollectionTypeBase
}
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
switch collection.Type {
case models.CollectionTypeView:
if err := txDao.saveViewCollection(collection, oldCollection); err != nil {
return err
}
default:
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
}
// sync the changes with the related records table
if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil {
return err
}
}
// sync the changes with the related records table
return txDao.SyncRecordTableSchema(collection, oldCollection)
return nil
})
if txErr != nil {
return txErr
}
// trigger an update for all views with changed schema as a result of the current collection save
// (ignoring view errors to allow users to update the query from the UI)
dao.resaveViewsWithChangedSchema(collection.Id)
return nil
}
// ImportCollections imports the provided collections list within a single transaction.
@@ -183,7 +224,7 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
func (dao *Dao) ImportCollections(
importedCollections []*models.Collection,
deleteMissing bool,
beforeRecordsSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
afterSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
) error {
if len(importedCollections) == 0 {
return errors.New("No collections to import")
@@ -191,7 +232,7 @@ func (dao *Dao) ImportCollections(
return dao.RunInTransaction(func(txDao *Dao) error {
existingCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&existingCollections); err != nil {
if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&existingCollections); err != nil {
return err
}
mappedExisting := make(map[string]*models.Collection, len(existingCollections))
@@ -247,6 +288,17 @@ func (dao *Dao) ImportCollections(
return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
}
// delete the related records table or view
if existing.IsView() {
if err := txDao.DeleteView(existing.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
}
// delete the collection
if err := txDao.Delete(existing); err != nil {
return err
@@ -261,29 +313,184 @@ func (dao *Dao) ImportCollections(
}
}
if beforeRecordsSync != nil {
if err := beforeRecordsSync(txDao, mappedImported, mappedExisting); err != nil {
// sync record tables
for _, imported := range importedCollections {
if imported.IsView() {
continue
}
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
}
}
// delete the record tables of the deleted collections
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
// sync views
for _, imported := range importedCollections {
if !imported.IsView() {
continue
}
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
existing := mappedExisting[imported.GetId()]
if err := txDao.saveViewCollection(imported, existing); err != nil {
return err
}
}
// sync the upserted collections with the related records table
for _, imported := range importedCollections {
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
if afterSync != nil {
if err := afterSync(txDao, mappedImported, mappedExisting); err != nil {
return err
}
}
return nil
})
}
// saveViewCollection persists the provided View collection changes:
// - deletes the old related SQL view (if any)
// - creates a new SQL view with the latest newCollection.Options.Query
// - generates a new schema based on newCollection.Options.Query
// - updates newCollection.Schema based on the generated view table info and query
// - saves the newCollection
//
// This method returns an error if newCollection is not a "view".
func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collection) error {
if !newCollection.IsView() {
return errors.New("not a view collection")
}
return dao.RunInTransaction(func(txDao *Dao) error {
query := newCollection.ViewOptions().Query
// generate collection schema from the query
viewSchema, err := txDao.CreateViewSchema(query)
if err != nil {
return err
}
// delete old renamed view
if oldCollection != nil {
if err := txDao.DeleteView(oldCollection.Name); err != nil {
return err
}
}
// 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
}
newCollection.Schema = viewSchema
return txDao.Save(newCollection)
})
}
// @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)
if err != nil {
return err
}
return dao.RunInTransaction(func(txDao *Dao) error {
for _, collection := range collections {
if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
continue
}
// clone the existing schema so that it is safe for temp modifications
oldSchema, err := collection.Schema.Clone()
if err != nil {
return err
}
// generate a new schema from the query
newSchema, err := txDao.CreateViewSchema(collection.ViewOptions().Query)
if err != nil {
return err
}
// unset the schema field ids to exclude from the comparison
for _, f := range oldSchema.Fields() {
f.Id = ""
}
for _, f := range newSchema.Fields() {
f.Id = ""
}
encodedNewSchema, err := json.Marshal(newSchema)
if err != nil {
return err
}
encodedOldSchema, err := json.Marshal(oldSchema)
if err != nil {
return err
}
if bytes.EqualFold(encodedNewSchema, encodedOldSchema) {
continue // no changes
}
if err := txDao.saveViewCollection(collection, nil); err != nil {
return err
}
}
+261 -35
View File
@@ -6,11 +6,13 @@ import (
"strings"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestCollectionQuery(t *testing.T) {
@@ -45,16 +47,16 @@ func TestFindCollectionsByType(t *testing.T) {
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if len(collections) != scenario.expectTotal {
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
t.Errorf("[%d] Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
}
for _, c := range collections {
if c.Type != scenario.collectionType {
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
t.Errorf("[%d] Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
}
}
}
@@ -80,11 +82,11 @@ func TestFindCollectionByNameOrId(t *testing.T) {
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
t.Errorf("[%d] Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
}
}
}
@@ -108,7 +110,7 @@ func TestIsCollectionNameUnique(t *testing.T) {
for i, scenario := range scenarios {
result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
}
}
}
@@ -155,7 +157,7 @@ func TestFindCollectionReferences(t *testing.T) {
}
for i, f := range fields {
if !list.ExistInSlice(f.Name, expectedFields) {
t.Fatalf("(%d) Didn't expect field %v", i, f)
t.Fatalf("[%d] Didn't expect field %v", i, f)
}
}
}
@@ -165,21 +167,39 @@ func TestDeleteCollection(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
c0 := &models.Collection{}
c1, err := app.Dao().FindCollectionByNameOrId("clients")
colUnsaved := &models.Collection{}
colAuth, err := app.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
c2, err := app.Dao().FindCollectionByNameOrId("demo2")
colReferenced, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
colSystem, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
c3.System = true
if err := app.Dao().Save(c3); err != nil {
colSystem.System = true
if err := app.Dao().Save(colSystem); err != nil {
t.Fatal(err)
}
colBase, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
colView2, err := app.Dao().FindCollectionByNameOrId("view2")
if err != nil {
t.Fatal(err)
}
@@ -187,18 +207,45 @@ func TestDeleteCollection(t *testing.T) {
model *models.Collection
expectError bool
}{
{c0, true},
{c1, false},
{c2, true}, // is part of a reference
{c3, true}, // system
{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}, // no longer has dependent views
{colAuth, false}, // should delete also its related external auths
}
for i, scenario := range scenarios {
err := app.Dao().DeleteCollection(scenario.model)
hasErr := err != nil
for i, s := range scenarios {
err := app.Dao().DeleteCollection(s.model)
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if app.Dao().HasTable(s.model.Name) {
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
}
// check if the external auths were deleted
if s.model.IsAuth() {
var total int
err := app.Dao().ExternalAuthQuery().
Select("count(*)").
AndWhere(dbx.HashExp{"collectionId": s.model.Id}).
Row(&total)
if err != nil || total > 0 {
t.Fatalf("[%d] Expected external auths to be deleted, got %v (%v)", i, total, err)
}
}
}
}
@@ -234,7 +281,7 @@ func TestSaveCollectionCreate(t *testing.T) {
}
// check if the records table has the schema fields
columns, err := app.Dao().GetTableColumns(collection.Name)
columns, err := app.Dao().TableColumns(collection.Name)
if err != nil {
t.Fatal(err)
}
@@ -244,7 +291,7 @@ func TestSaveCollectionCreate(t *testing.T) {
}
for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c)
t.Fatalf("[%d] Didn't expect record column %s", i, c)
}
}
}
@@ -273,7 +320,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
// check if the records table has the schema fields
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
columns, err := app.Dao().GetTableColumns(collection.Name)
columns, err := app.Dao().TableColumns(collection.Name)
if err != nil {
t.Fatal(err)
}
@@ -282,12 +329,183 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c)
t.Fatalf("[%d] Didn't expect record column %s", i, c)
}
}
}
// indirect update of a field used in view should cause view(s) update
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
// update MaxSelect fields
{
relMany := collection.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
relManyOpt.MaxSelect = types.Pointer(1)
fileOne := collection.Schema.GetFieldByName("file_one")
fileOneOpt := fileOne.Options.(*schema.FileOptions)
fileOneOpt.MaxSelect = 10
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
}
// check view1 schema
{
view1, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
relMany := view1.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
}
fileOne := view1.Schema.GetFieldByName("file_one")
fileOneOpt := fileOne.Options.(*schema.FileOptions)
if fileOneOpt.MaxSelect != 10 {
t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOneOpt.MaxSelect)
}
}
// check view2 schema
{
view2, err := app.Dao().FindCollectionByNameOrId("view2")
if err != nil {
t.Fatal(err)
}
relMany := view2.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
}
}
}
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 := 11
scenarios := []struct {
name string
jsonData string
@@ -302,7 +520,7 @@ func TestImportCollections(t *testing.T) {
name: "empty collections",
jsonData: `[]`,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
},
{
name: "minimal collection import",
@@ -312,7 +530,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 10,
expectCollectionsCount: totalCollections + 2,
},
{
name: "minimal collection import + failed beforeRecordsSync",
@@ -324,7 +542,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
},
{
name: "minimal collection import + successful beforeRecordsSync",
@@ -336,7 +554,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: false,
expectCollectionsCount: 9,
expectCollectionsCount: totalCollections + 1,
},
{
name: "new + update + delete system collection",
@@ -372,7 +590,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: true,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
},
{
name: "new + update + delete non-system collection",
@@ -415,7 +633,7 @@ func TestImportCollections(t *testing.T) {
},
{
"id":"wsmn24bux7wo113",
"name":"demo",
"name":"demo1_rename",
"schema":[
{
"id":"_2hlxbmp",
@@ -442,11 +660,19 @@ func TestImportCollections(t *testing.T) {
"type":"bool"
}
]
},
{
"id": "test_new_view",
"name": "new_view",
"type": "view",
"options": {
"query": "select id from demo2"
}
}
]`,
deleteMissing: true,
expectError: false,
expectCollectionsCount: 3,
expectCollectionsCount: 4,
},
{
name: "test with deleteMissing: false",
@@ -501,7 +727,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 9,
expectCollectionsCount: totalCollections + 1,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
"nologin": 1,
@@ -509,7 +735,7 @@ func TestImportCollections(t *testing.T) {
"demo2": 2,
"demo3": 2,
"demo4": 11,
"demo5": 5,
"demo5": 6,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
+20 -23
View File
@@ -12,8 +12,8 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.ExternalAuth{})
}
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
/// linked to the provided auth record.
// FindAllExternalAuthsByRecord returns all ExternalAuth models
// linked to the provided auth record.
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
auths := []*models.ExternalAuth{}
@@ -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)
}
}
+318 -47
View File
@@ -1,6 +1,8 @@
package daos
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
@@ -8,19 +10,123 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// RecordQuery returns a new Record select query.
func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
tableName := collection.Name
// RecordQuery returns a new Record select query from a collection model, id or name.
//
// In case a collection id or name is provided and that collection doesn't
// actually exists, the generated query will be created with a cancelled context
// and will fail once an executor (Row(), One(), All(), etc.) is called.
func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
var tableName string
var collection *models.Collection
var collectionErr error
switch c := collectionModelOrIdentifier.(type) {
case *models.Collection:
collection = c
tableName = collection.Name
case models.Collection:
collection = &c
tableName = collection.Name
case string:
collection, collectionErr = dao.FindCollectionByNameOrId(c)
if collection != nil {
tableName = collection.Name
} else {
// update with some fake table name for easier debugging
tableName = "@@__missing_" + c
}
default:
// update with some fake table name for easier debugging
tableName = "@@__invalidCollectionModelOrIdentifier"
collectionErr = errors.New("unsupported collection identifier, must be collection model, id or name")
}
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
return dao.DB().Select(selectCols).From(tableName)
query := dao.DB().Select(selectCols).From(tableName)
// in case of an error attach a new context and cancel it immediately with the error
if collectionErr != nil {
// @todo consider changing to WithCancelCause when upgrading
// the min Go requirement to 1.20, so that we can pass the error
ctx, cancelFunc := context.WithCancel(context.Background())
query.WithContext(ctx)
cancelFunc()
}
return query.WithBuildHook(func(q *dbx.Query) {
q.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
switch v := a.(type) {
case *models.Record:
if v == nil {
return op(a)
}
row := dbx.NullStringMap{}
if err := op(&row); err != nil {
return err
}
record := models.NewRecordFromNullStringMap(collection, row)
*v = *record
return nil
default:
return op(a)
}
}).
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
switch v := sliceA.(type) {
case *[]*models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
*v = records
return nil
case *[]models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
nonPointers := make([]models.Record, len(records))
for i, r := range records {
nonPointers[i] = *r
}
*v = nonPointers
return nil
default:
return op(sliceA)
}
})
})
}
// FindRecordById finds the Record model by its id.
@@ -34,10 +140,8 @@ func (dao *Dao) FindRecordById(
return nil, err
}
tableName := collection.Name
query := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{tableName + ".id": recordId})
AndWhere(dbx.HashExp{collection.Name + ".id": recordId})
for _, filter := range optFilters {
if filter == nil {
@@ -48,12 +152,13 @@ func (dao *Dao) FindRecordById(
}
}
row := dbx.NullStringMap{}
if err := query.Limit(1).One(row); err != nil {
record := &models.Record{}
if err := query.Limit(1).One(record); err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// FindRecordsByIds finds all Record models by the provided ids.
@@ -83,14 +188,17 @@ func (dao *Dao) FindRecordsByIds(
}
}
rows := make([]dbx.NullStringMap, 0, len(recordIds))
if err := query.All(&rows); err != nil {
records := make([]*models.Record, 0, len(recordIds))
if err := query.All(&records); err != nil {
return nil, err
}
return models.NewRecordsFromNullStringMaps(collection, rows), nil
return records, nil
}
// @todo consider to depricate as it may be easier to just use dao.RecordQuery()
//
// FindRecordsByExpr finds all records by the specified db expression.
//
// Returns all collection records if no expressions are provided.
@@ -103,12 +211,7 @@ func (dao *Dao) FindRecordsByIds(
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
// dao.FindRecordsByExpr("example", expr1, expr2)
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
query := dao.RecordQuery(collection)
query := dao.RecordQuery(collectionNameOrId)
// add only the non-nil expressions
for _, expr := range exprs {
@@ -117,35 +220,140 @@ func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expres
}
}
rows := []dbx.NullStringMap{}
var records []*models.Record
if err := query.All(&rows); err != nil {
if err := query.All(&records); err != nil {
return nil, err
}
return models.NewRecordsFromNullStringMaps(collection, rows), nil
return records, nil
}
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
func (dao *Dao) FindFirstRecordByData(
collectionNameOrId string,
key string,
value any,
) (*models.Record, error) {
record := &models.Record{}
err := dao.RecordQuery(collectionNameOrId).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(record)
if err != nil {
return nil, err
}
return record, nil
}
// 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.
//
// Example:
//
// 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 {
return nil, err
}
row := dbx.NullStringMap{}
q := dao.RecordQuery(collection)
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(row)
// build a fields resolver and attach the generated conditions to the query
// ---
resolver := resolvers.NewRecordFieldResolver(
dao,
collection, // the base collection
nil, // no request data
true, // allow searching hidden/protected fields like "email"
)
expr, err := search.FilterData(filter).BuildExpr(resolver, params...)
if err != nil || expr == nil {
return nil, errors.New("invalid or empty filter expression")
}
q.AndWhere(expr)
if sort != "" {
for _, sortField := range search.ParseSortFromString(sort) {
expr, err := sortField.BuildExpr(resolver)
if err != nil {
return nil, err
}
if expr != "" {
q.AndOrderBy(expr)
}
}
}
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
// ---
if offset > 0 {
q.Offset(int64(offset))
}
if limit > 0 {
q.Limit(int64(limit))
}
records := []*models.Record{}
if err := q.All(&records); err != nil {
return nil, err
}
return records, nil
}
// FindFirstRecordByFilter returns the first available record matching the provided filter.
//
// NB! Use the last params argument to bind untrusted user variables!
//
// Example:
//
// 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
}
return models.NewRecordFromNullStringMap(collection, row), nil
if len(result) == 0 {
return nil, sql.ErrNoRows
}
return result[0], nil
}
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
@@ -177,9 +385,9 @@ func (dao *Dao) IsRecordValueUnique(
var normalizedVal any
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
normalizedVal = append(types.JsonArray[string]{}, val...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
normalizedVal = append(types.JsonArray[any]{}, val...)
default:
normalizedVal = val
}
@@ -201,9 +409,9 @@ func (dao *Dao) IsRecordValueUnique(
return query.Row(&exists) == nil && !exists
}
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
// FindAuthRecordByToken finds the auth record associated with the provided JWT.
//
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
// Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
@@ -248,18 +456,17 @@ func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
@@ -274,20 +481,19 @@ func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username str
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
}
row := dbx.NullStringMap{}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(username),
})).
Limit(1).
One(row)
One(record)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
return record, nil
}
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
@@ -318,7 +524,66 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
return username
}
// SaveRecord upserts the provided Record model.
// CanAccessRecord checks if a record is allowed to be accessed by the
// specified requestInfo and accessRule.
//
// Rule and db checks are ignored in case requestInfo.Admin is set.
//
// The returned error indicate that something unexpected happened during
// the check (eg. invalid rule or db error).
//
// The method always return false on invalid access rule or db error.
//
// Example:
//
// requestInfo := apis.RequestInfo(c /* echo.Context */)
// record, _ := dao.FindRecordById("example", "RECORD_ID")
// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
//
// if ok, _ := dao.CanAccessRecord(record, requestInfo, rule); ok { ... }
func (dao *Dao) CanAccessRecord(record *models.Record, requestInfo *models.RequestInfo, accessRule *string) (bool, error) {
if requestInfo.Admin != nil {
// admins can access everything
return true, nil
}
if accessRule == nil {
// only admins can access this record
return false, nil
}
if *accessRule == "" {
// empty public rule, aka. everyone can access
return true, nil
}
var exists bool
query := dao.RecordQuery(record.Collection()).
Select("(1)").
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
// parse and apply the access rule filter
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return false, err
}
resolver.UpdateQuery(query)
query.AndWhere(expr)
if err := query.Limit(1).Row(&exists); err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
return exists, nil
}
// SaveRecord persists the provided Record model in the database.
//
// If record.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a record for update you can use record.MarkAsNotNew().
func (dao *Dao) SaveRecord(record *models.Record) error {
if record.Collection().IsAuth() {
if record.Username() == "" {
@@ -396,18 +661,24 @@ func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
for refCollection, fields := range refs {
if refCollection.IsView() {
continue // skip view collections
}
for _, field := range fields {
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
// @todo optimize single relation lookup in v0.12+
query := dao.RecordQuery(refCollection).
Distinct(true).
InnerJoin(fmt.Sprintf(
// note: the case is used to normalize the value access
query := dao.RecordQuery(refCollection).Distinct(true)
if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
} else {
query.InnerJoin(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
}
if refCollection.Id == mainRecord.Collection().Id {
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
+29 -9
View File
@@ -1,7 +1,6 @@
package daos
import (
"errors"
"fmt"
"regexp"
"strings"
@@ -9,6 +8,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
@@ -23,21 +23,27 @@ type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]
// ExpandRecord expands the relations of a single Record model.
//
// If optFetchFunc is not set, then a default function will be used
// that returns all relation records.
//
// Returns a map with the failed expand parameters and their errors.
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc)
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
return dao.ExpandRecords([]*models.Record{record}, expands, optFetchFunc)
}
// ExpandRecords expands the relations of the provided Record models list.
//
// If optFetchFunc is not set, then a default function will be used
// that returns all relation records.
//
// Returns a map with the failed expand parameters and their errors.
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
normalized := normalizeExpands(expands)
failed := map[string]error{}
for _, expand := range normalized {
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
if err := dao.expandRecords(records, expand, optFetchFunc, 1); err != nil {
failed[expand] = err
}
}
@@ -48,13 +54,16 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
// notes:
// - fetchFunc must be non-nil func
// - if fetchFunc is nil, dao.FindRecordsByIds will be used
// - all records are expected to be from the same collection
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
// - indirect expands are supported only with single relation fields
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
if fetchFunc == nil {
return errors.New("Relation records fetchFunc is not set.")
// load a default fetchFunc
fetchFunc = func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
return dao.FindRecordsByIds(relCollection.Id, relIds)
}
}
if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 {
@@ -86,7 +95,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
}
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
if indirectRelFieldOptions.IsMultiple() {
// for now don't allow multi-relation indirect fields expand
// due to eventual poor query performance with large data sets.
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
@@ -126,7 +135,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
MaxSelect: nil,
CollectionId: indirectRel.Id,
}
if indirectRelField.Unique {
if isRelFieldUnique(indirectRel, indirectRelField.Name) {
relFieldOptions.MaxSelect = types.Pointer(1)
}
// indirect relation
@@ -269,3 +278,14 @@ func normalizeExpands(paths []string) []string {
return list.ToUniqueStringSlice(result)
}
func isRelFieldUnique(collection *models.Collection, fieldName string) bool {
for _, idx := range collection.Indexes {
parsed := dbutils.ParseIndex(idx)
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, fieldName) {
return true
}
}
return false
}
+80 -18
View File
@@ -48,15 +48,6 @@ func TestExpandRecords(t *testing.T) {
0,
0,
},
{
"empty fetchFunc",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
{
"fetchFunc with error",
"demo4",
@@ -101,6 +92,19 @@ func TestExpandRecords(t *testing.T) {
0,
1,
},
{
"with nil fetchfunc",
"users",
[]string{
"bgs820n361vj1qd",
"4q1xlclmfloku33",
"oap640cot4yru2s", // no rels
},
[]string{"rel"},
nil,
2,
0,
},
{
"expand normalizations",
"demo4",
@@ -132,6 +136,19 @@ func TestExpandRecords(t *testing.T) {
2,
0,
},
{
"with nil fetchfunc",
"users",
[]string{
"bgs820n361vj1qd",
"4q1xlclmfloku33",
"oap640cot4yru2s", // no rels
},
[]string{"rel"},
nil,
2,
0,
},
{
"maxExpandDepth reached",
"demo4",
@@ -228,15 +245,6 @@ func TestExpandRecord(t *testing.T) {
0,
0,
},
{
"empty fetchFunc",
"demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
{
"fetchFunc with error",
"demo4",
@@ -362,3 +370,57 @@ func TestExpandRecord(t *testing.T) {
}
}
}
func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
record, err := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
if err != nil {
t.Fatal(err)
}
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].([]*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a slice, got %v", result)
}
}
// mock a unique constraint for the rel_one_cascade field
{
demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)")
if err := app.Dao().SaveCollection(demo4); err != nil {
t.Fatalf("Failed to mock unique constraint: %v", err)
}
}
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].(*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a single model, got %v", result)
}
}
}
+254 -92
View File
@@ -2,11 +2,14 @@ package daos
import (
"fmt"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -15,70 +18,67 @@ import (
//
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
// create
if oldCollection == nil {
cols := map[string]string{
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
}
return dao.RunInTransaction(func(txDao *Dao) error {
// create
// -----------------------------------------------------------
if oldCollection == nil {
cols := map[string]string{
schema.FieldNameId: "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL",
schema.FieldNameCreated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
}
if newCollection.IsAuth() {
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
}
if newCollection.IsAuth() {
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
}
// ensure that the new collection has an id
if !newCollection.HasId() {
newCollection.RefreshId()
newCollection.MarkAsNew()
}
// ensure that the new collection has an id
if !newCollection.HasId() {
newCollection.RefreshId()
newCollection.MarkAsNew()
}
tableName := newCollection.Name
tableName := newCollection.Name
// add schema field definitions
for _, field := range newCollection.Schema.Fields() {
cols[field.Name] = field.ColDefinition()
}
// add schema field definitions
for _, field := range newCollection.Schema.Fields() {
cols[field.Name] = field.ColDefinition()
}
// create table
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add named index on the base `created` column
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := dao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
// create table
if _, err := txDao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := txDao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
return err
}
}
return txDao.createCollectionIndexes(newCollection)
}
return nil
}
// update
return dao.RunInTransaction(func(txDao *Dao) error {
// update
// -----------------------------------------------------------
oldTableName := oldCollection.Name
newTableName := newCollection.Name
oldSchema := oldCollection.Schema
@@ -86,9 +86,14 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
deletedFieldNames := []string{}
renamedFieldNames := map[string]string{}
// drop old indexes (if any)
if err := txDao.dropCollectionIndex(oldCollection); err != nil {
return err
}
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
_, err := txDao.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
if err != nil {
return err
}
@@ -102,7 +107,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
if err != nil {
return err
return fmt.Errorf("failed to drop column %s - %w", oldField.Name, err)
}
deletedFieldNames = append(deletedFieldNames, oldField.Name)
@@ -125,7 +130,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// add
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
if err != nil {
return err
return fmt.Errorf("failed to add column %s - %w", field.Name, err)
}
} else if oldField.Name != field.Name {
tempName := field.Name + security.PseudorandomString(5)
@@ -134,7 +139,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// rename
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
if err != nil {
return err
return fmt.Errorf("failed to rename column %s - %w", oldField.Name, err)
}
renamedFieldNames[oldField.Name] = field.Name
@@ -149,50 +154,207 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
}
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
if err := txDao.normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection); err != nil {
return err
}
return txDao.createCollectionIndexes(newCollection)
})
}
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
return nil // nothing to sync
func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection *models.Collection) error {
if newCollection.IsView() || oldCollection == nil {
return nil // view or not an update
}
refs, err := dao.FindCollectionReferences(collection)
if err != nil {
return err
return dao.RunInTransaction(func(txDao *Dao) error {
// temporary disable the schema error checks to prevent view and trigger errors
// when "altering" (aka. deleting and recreating) the non-normalized columns
if _, err := txDao.DB().NewQuery("PRAGMA writable_schema = ON").Execute(); err != nil {
return err
}
// executed with defer to make sure that the pragma is always reverted
// in case of an error and when nested transactions are used
defer txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
for _, newField := range newCollection.Schema.Fields() {
// allow to continue even if there is no old field for the cases
// when a new field is added and there are already inserted data
var isOldMultiple bool
if oldField := oldCollection.Schema.GetFieldById(newField.Id); oldField != nil {
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
isOldMultiple = opt.IsMultiple()
}
}
var isNewMultiple bool
if opt, ok := newField.Options.(schema.MultiValuer); ok {
isNewMultiple = opt.IsMultiple()
}
if isOldMultiple == isNewMultiple {
continue // no change
}
// update the column definition by:
// 1. inserting a new column with the new definition
// 2. copy normalized values from the original column to the new one
// 3. drop the original column
// 4. rename the new column to the original column
// -------------------------------------------------------
originalName := newField.Name
tempName := "_" + newField.Name + security.PseudorandomString(5)
_, err := txDao.DB().AddColumn(newCollection.Name, tempName, newField.ColDefinition()).Execute()
if err != nil {
return err
}
var copyQuery *dbx.Query
if !isOldMultiple && isNewMultiple {
// single -> multiple (convert to array)
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '') = ''
THEN '[]'
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN [[%s]]
ELSE json_array([[%s]])
END
)
END
)`,
newCollection.Name,
tempName,
originalName,
originalName,
originalName,
originalName,
originalName,
))
} else {
// multiple -> single (keep only the last element)
//
// note: for file fields the actual file objects are not
// deleted allowing additional custom handling via migration
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '[]') = '[]'
THEN ''
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
ELSE [[%s]]
END
)
END
)`,
newCollection.Name,
tempName,
originalName,
originalName,
originalName,
originalName,
originalName,
))
}
// copy the normalized values
if _, err := copyQuery.Execute(); err != nil {
return err
}
// drop the original column
if _, err := txDao.DB().DropColumn(newCollection.Name, originalName).Execute(); err != nil {
return err
}
// rename the new column back to the original
if _, err := txDao.DB().RenameColumn(newCollection.Name, tempName, originalName).Execute(); err != nil {
return err
}
}
// revert the pragma and reload the schema
_, revertErr := txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
return revertErr
})
}
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
}
for refCollection, refFields := range refs {
for _, refField := range refFields {
options, _ := refField.Options.(*schema.RelationOptions)
if options == nil {
return dao.RunInTransaction(func(txDao *Dao) error {
for _, raw := range collection.Indexes {
parsed := dbutils.ParseIndex(raw)
if !parsed.IsValid() {
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
}
if _, err := txDao.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
return err
}
}
return nil
})
}
func (dao *Dao) createCollectionIndexes(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
}
return nil
return dao.RunInTransaction(func(txDao *Dao) error {
// drop new indexes in case a duplicated index name is used
if err := txDao.dropCollectionIndex(collection); err != nil {
return err
}
// upsert new indexes
//
// note: we are returning validation errors because the indexes cannot be
// validated in a form, aka. before persisting the related collection
// record table changes
errs := validation.Errors{}
for i, idx := range collection.Indexes {
parsed := dbutils.ParseIndex(idx)
// ensure that the index is always for the current collection
parsed.TableName = collection.Name
if !parsed.IsValid() {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
"Invalid CREATE INDEX expression.",
)
continue
}
if _, err := txDao.DB().NewQuery(parsed.Build()).Execute(); err != nil {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
)
continue
}
}
if len(errs) > 0 {
return validation.Errors{"indexes": errs}
}
return nil
})
}
+222 -22
View File
@@ -1,12 +1,16 @@
package daos_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestSyncRecordTableSchema(t *testing.T) {
@@ -36,15 +40,17 @@ func TestSyncRecordTableSchema(t *testing.T) {
Type: schema.FieldTypeEmail,
},
)
updatedCollection.Indexes = types.JsonArray[string]{"create index idx_title_renamed on anything (title_renamed)"}
scenarios := []struct {
newCollection *models.Collection
oldCollection *models.Collection
expectedTableName string
expectedColumns []string
name string
newCollection *models.Collection
oldCollection *models.Collection
expectedColumns []string
expectedIndexesCount int
}{
// new base collection
{
"new base collection",
&models.Collection{
Name: "new_table",
Schema: schema.NewSchema(
@@ -55,11 +61,11 @@ func TestSyncRecordTableSchema(t *testing.T) {
),
},
nil,
"new_table",
[]string{"id", "created", "updated", "test"},
0,
},
// new auth collection
{
"new auth collection",
&models.Collection{
Name: "new_table_auth",
Type: models.CollectionTypeAuth,
@@ -69,51 +75,245 @@ func TestSyncRecordTableSchema(t *testing.T) {
Type: schema.FieldTypeText,
},
),
Indexes: types.JsonArray[string]{"create index idx_auth_test on anything (email, username)"},
},
nil,
"new_table_auth",
[]string{
"id", "created", "updated", "test",
"username", "email", "verified", "emailVisibility",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
},
4,
},
// no changes
{
"no changes",
oldCollection,
oldCollection,
"demo3",
[]string{"id", "created", "updated", "title", "active"},
3,
},
// renamed table, deleted column, renamed columnd and new column
{
"renamed table, deleted column, renamed columnd and new column",
updatedCollection,
oldCollection,
"demo_renamed",
[]string{"id", "created", "updated", "title_renamed", "new_field"},
1,
},
}
for i, scenario := range scenarios {
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
for _, s := range scenarios {
err := app.Dao().SyncRecordTableSchema(s.newCollection, s.oldCollection)
if err != nil {
t.Errorf("(%d) %v", i, err)
t.Errorf("[%s] %v", s.name, err)
continue
}
if !app.Dao().HasTable(scenario.newCollection.Name) {
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
if !app.Dao().HasTable(s.newCollection.Name) {
t.Errorf("[%s] Expected table %s to exist", s.name, s.newCollection.Name)
}
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
if len(cols) != len(scenario.expectedColumns) {
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
cols, _ := app.Dao().TableColumns(s.newCollection.Name)
if len(cols) != len(s.expectedColumns) {
t.Errorf("[%s] Expected columns %v, got %v", s.name, s.expectedColumns, cols)
}
for _, c := range cols {
if !list.ExistInSlice(c, scenario.expectedColumns) {
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
if !list.ExistInSlice(c, s.expectedColumns) {
t.Errorf("[%s] Couldn't find column %s in %v", s.name, c, s.expectedColumns)
}
}
indexes, _ := app.Dao().TableIndexes(s.newCollection.Name)
if totalIndexes := len(indexes); totalIndexes != s.expectedIndexesCount {
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.name, s.expectedIndexesCount, totalIndexes, indexes)
}
}
}
func TestSingleVsMultipleValuesNormalization(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
// mock field changes
{
selectOneField := collection.Schema.GetFieldByName("select_one")
opt := selectOneField.Options.(*schema.SelectOptions)
opt.MaxSelect = 2
}
{
selectManyField := collection.Schema.GetFieldByName("select_many")
opt := selectManyField.Options.(*schema.SelectOptions)
opt.MaxSelect = 1
}
{
fileOneField := collection.Schema.GetFieldByName("file_one")
opt := fileOneField.Options.(*schema.FileOptions)
opt.MaxSelect = 2
}
{
fileManyField := collection.Schema.GetFieldByName("file_many")
opt := fileManyField.Options.(*schema.FileOptions)
opt.MaxSelect = 1
}
{
relOneField := collection.Schema.GetFieldByName("rel_one")
opt := relOneField.Options.(*schema.RelationOptions)
opt.MaxSelect = types.Pointer(2)
}
{
relManyField := collection.Schema.GetFieldByName("rel_many")
opt := relManyField.Options.(*schema.RelationOptions)
opt.MaxSelect = types.Pointer(1)
}
{
// new multivaluer field to check whether the array normalization
// will be applied for already inserted data
collection.Schema.AddField(&schema.SchemaField{
Name: "new_multiple",
Type: schema.FieldTypeSelect,
Options: &schema.SelectOptions{
Values: []string{"a", "b", "c"},
MaxSelect: 3,
},
})
}
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
// ensures that the writable schema was reverted to its expected default
var writableSchema bool
app.Dao().DB().NewQuery("PRAGMA writable_schema").Row(&writableSchema)
if writableSchema == true {
t.Fatalf("Expected writable_schema to be OFF, got %v", writableSchema)
}
// check whether the columns DEFAULT definition was updated
// ---------------------------------------------------------------
tableInfo, err := app.Dao().TableInfo(collection.Name)
if err != nil {
t.Fatal(err)
}
tableInfoExpectations := map[string]string{
"select_one": `'[]'`,
"select_many": `''`,
"file_one": `'[]'`,
"file_many": `''`,
"rel_one": `'[]'`,
"rel_many": `''`,
"new_multiple": `'[]'`,
}
for col, dflt := range tableInfoExpectations {
t.Run("check default for "+col, func(t *testing.T) {
var row *models.TableInfoRow
for _, r := range tableInfo {
if r.Name == col {
row = r
break
}
}
if row == nil {
t.Fatalf("Missing info for column %q", col)
}
if v := row.DefaultValue.String(); v != dflt {
t.Fatalf("Expected default value %q, got %q", dflt, v)
}
})
}
// check whether the values were normalized
// ---------------------------------------------------------------
type fieldsExpectation struct {
SelectOne string `db:"select_one"`
SelectMany string `db:"select_many"`
FileOne string `db:"file_one"`
FileMany string `db:"file_many"`
RelOne string `db:"rel_one"`
RelMany string `db:"rel_many"`
NewMultiple string `db:"new_multiple"`
}
fieldsScenarios := []struct {
recordId string
expected fieldsExpectation
}{
{
"imy661ixudk5izi",
fieldsExpectation{
SelectOne: `[]`,
SelectMany: ``,
FileOne: `[]`,
FileMany: ``,
RelOne: `[]`,
RelMany: ``,
NewMultiple: `[]`,
},
},
{
"al1h9ijdeojtsjy",
fieldsExpectation{
SelectOne: `["optionB"]`,
SelectMany: `optionB`,
FileOne: `["300_Jsjq7RdBgA.png"]`,
FileMany: ``,
RelOne: `["84nmscqy84lsi1t"]`,
RelMany: `oap640cot4yru2s`,
NewMultiple: `[]`,
},
},
{
"84nmscqy84lsi1t",
fieldsExpectation{
SelectOne: `["optionB"]`,
SelectMany: `optionC`,
FileOne: `["test_d61b33QdDU.txt"]`,
FileMany: `test_tC1Yc87DfC.txt`,
RelOne: `[]`,
RelMany: `oap640cot4yru2s`,
NewMultiple: `[]`,
},
},
}
for _, s := range fieldsScenarios {
t.Run("check fields for record "+s.recordId, func(t *testing.T) {
result := new(fieldsExpectation)
err := app.Dao().DB().Select(
"select_one",
"select_many",
"file_one",
"file_many",
"rel_one",
"rel_many",
"new_multiple",
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
if err != nil {
t.Fatalf("Failed to load record: %v", err)
}
encodedResult, err := json.Marshal(result)
if err != nil {
t.Fatalf("Failed to encode result: %v", err)
}
encodedExpectation, err := json.Marshal(s.expected)
if err != nil {
t.Fatalf("Failed to encode expectation: %v", err)
}
if !bytes.EqualFold(encodedExpectation, encodedResult) {
t.Fatalf("Expected \n%s, \ngot \n%s", encodedExpectation, encodedResult)
}
})
}
}
+505 -12
View File
@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"testing"
@@ -19,7 +18,7 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRecordQuery(t *testing.T) {
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -28,11 +27,118 @@ func TestRecordQuery(t *testing.T) {
t.Fatal(err)
}
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
scenarios := []struct {
name any
collection any
expectedTotal int
expectError bool
}{
{"with nil value", nil, 0, true},
{"with invalid or missing collection id/name", "missing", 0, true},
{"with pointer model", collection, 3, false},
{"with value model", *collection, 3, false},
{"with name", "demo1", 3, false},
{"with id", "wsmn24bux7wo113", 3, false},
}
sql := app.Dao().RecordQuery(collection).Build().SQL()
if sql != expected {
t.Errorf("Expected sql %s, got %s", expected, sql)
for _, s := range scenarios {
var records []*models.Record
err := app.Dao().RecordQuery(s.collection).All(&records)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasError %v, got %v", s.name, s.expectError, hasErr)
continue
}
if total := len(records); total != s.expectedTotal {
t.Errorf("[%s] Expected %d records, got %d", s.name, s.expectedTotal, total)
}
}
}
func TestRecordQueryOneWithRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
id := "84nmscqy84lsi1t"
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": id})
record := &models.Record{}
if err := q.One(record); err != nil {
t.Fatal(err)
}
if record.GetString("id") != id {
t.Fatalf("Expected record with id %q, got %q", id, record.GetString("id"))
}
}
func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
id1 := "84nmscqy84lsi1t"
id2 := "al1h9ijdeojtsjy"
{
records := []models.Record{}
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": []any{id1, id2}}).
OrderBy("created asc")
if err := q.All(&records); err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatalf("Expected %d records, got %d", 2, len(records))
}
if records[0].Id != id1 {
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
}
if records[1].Id != id2 {
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
}
}
{
records := []*models.Record{}
q := app.Dao().RecordQuery(collection).
Where(dbx.HashExp{"id": []any{id1, id2}}).
OrderBy("created asc")
if err := q.All(&records); err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatalf("Expected %d records, got %d", 2, len(records))
}
if records[0].Id != id1 {
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
}
if records[1].Id != id2 {
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
}
}
}
@@ -320,6 +426,393 @@ func TestFindFirstRecordByData(t *testing.T) {
}
}
func TestFindRecordsByFilter(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
collectionIdOrName string
filter string
sort string
limit int
offset int
params []dbx.Params
expectError bool
expectRecordIds []string
}{
{
"missing collection",
"missing",
"id != ''",
"",
0,
0,
nil,
true,
nil,
},
{
"missing filter",
"demo2",
"",
"",
0,
0,
nil,
true,
nil,
},
{
"invalid filter",
"demo2",
"someMissingField > 1",
"",
0,
0,
nil,
true,
nil,
},
{
"simple filter",
"demo2",
"id != ''",
"",
0,
0,
nil,
false,
[]string{
"llvuca81nly1qls",
"achvryl401bhse3",
"0yxhwia2amd8gec",
},
},
{
"multi-condition filter with sort",
"demo2",
"id != '' && active=true",
"-created,title",
-1, // should behave the same as 0
0,
nil,
false,
[]string{
"0yxhwia2amd8gec",
"achvryl401bhse3",
},
},
{
"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",
},
},
}
for _, s := range scenarios {
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.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)
}
}
})
}
}
func TestFindFirstRecordByFilter(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
collectionIdOrName string
filter string
params []dbx.Params
expectError bool
expectRecordId string
}{
{
"missing collection",
"missing",
"id != ''",
nil,
true,
"",
},
{
"missing filter",
"demo2",
"",
nil,
true,
"",
},
{
"invalid filter",
"demo2",
"someMissingField > 1",
nil,
true,
"",
},
{
"valid filter but no matches",
"demo2",
"id = 'test'",
nil,
true,
"",
},
{
"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, 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 record.Id != s.expectRecordId {
t.Errorf("[%s] Expected record with id %q, got %q", s.name, s.expectRecordId, record.Id)
}
}
}
func TestCanAccessRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
admin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
authRecord, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
record, err := app.Dao().FindRecordById("demo1", "imy661ixudk5izi")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
record *models.Record
requestInfo *models.RequestInfo
rule *string
expected bool
expectError bool
}{
{
"as admin with nil rule",
record,
&models.RequestInfo{
Admin: admin,
},
nil,
true,
false,
},
{
"as admin with non-empty rule",
record,
&models.RequestInfo{
Admin: admin,
},
types.Pointer("id = ''"), // the filter rule should be ignored
true,
false,
},
{
"as admin with invalid rule",
record,
&models.RequestInfo{
Admin: admin,
},
types.Pointer("id ?!@ 1"), // the filter rule should be ignored
true,
false,
},
{
"as guest with nil rule",
record,
&models.RequestInfo{},
nil,
false,
false,
},
{
"as guest with empty rule",
record,
&models.RequestInfo{},
types.Pointer(""),
true,
false,
},
{
"as guest with invalid rule",
record,
&models.RequestInfo{},
types.Pointer("id ?!@ 1"),
false,
true,
},
{
"as guest with mismatched rule",
record,
&models.RequestInfo{},
types.Pointer("@request.auth.id != ''"),
false,
false,
},
{
"as guest with matched rule",
record,
&models.RequestInfo{
Data: map[string]any{"test": 1},
},
types.Pointer("@request.auth.id != '' || @request.data.test = 1"),
true,
false,
},
{
"as auth record with nil rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
},
nil,
false,
false,
},
{
"as auth record with empty rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
},
types.Pointer(""),
true,
false,
},
{
"as auth record with invalid rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
},
types.Pointer("id ?!@ 1"),
false,
true,
},
{
"as auth record with mismatched rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
Data: map[string]any{"test": 1},
},
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
false,
false,
},
{
"as auth record with matched rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
Data: map[string]any{"test": 2},
},
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
true,
false,
},
}
for _, s := range scenarios {
result, err := app.Dao().CanAccessRecord(s.record, s.requestInfo, s.rule)
if result != s.expected {
t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
}
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
}
}
func TestIsRecordValueUnique(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -663,13 +1156,13 @@ func TestDeleteRecord(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
expectedRelManyPart := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
}
expectedRelOneJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
expectedRelOnePart := "SELECT DISTINCT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`="
if !strings.Contains(joinedQueries, expectedRelOnePart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
}
}
-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.DB().Delete(tableName, expr).Execute()
return err
}
// SaveRequest upserts the provided Request model.
func (dao *Dao) SaveRequest(request *models.Request) error {
return dao.Save(request)
}
+66 -5
View File
@@ -1,16 +1,19 @@
package daos
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
)
// HasTable checks if a table with the provided name exists (case insensitive).
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
func (dao *Dao) HasTable(tableName string) bool {
var exists bool
err := dao.DB().Select("count(*)").
From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "table"}).
AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
Limit(1).
Row(&exists)
@@ -18,8 +21,8 @@ func (dao *Dao) HasTable(tableName string) bool {
return err == nil && exists
}
// GetTableColumns returns all column names of a single table by its name.
func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
// TableColumns returns all column names of a single table by its name.
func (dao *Dao) TableColumns(tableName string) ([]string, error) {
columns := []string{}
err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
@@ -29,9 +32,67 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
return columns, err
}
// TableInfo returns the `table_info` pragma result for the specified table.
func (dao *Dao) TableInfo(tableName string) ([]*models.TableInfoRow, error) {
info := []*models.TableInfoRow{}
err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
All(&info)
if err != nil {
return nil, err
}
// mattn/go-sqlite3 doesn't throw an error on invalid or missing table
// so we additionally have to check whether the loaded info result is nonempty
if len(info) == 0 {
return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
}
return info, nil
}
// TableIndexes returns a name grouped map with all non empty index of the specified table.
//
// Note: This method doesn't return an error on nonexisting table.
func (dao *Dao) TableIndexes(tableName string) (map[string]string, error) {
indexes := []struct {
Name string
Sql string
}{}
err := dao.DB().Select("name", "sql").
From("sqlite_master").
AndWhere(dbx.NewExp("sql is not null")).
AndWhere(dbx.HashExp{
"type": "index",
"tbl_name": tableName,
}).
All(&indexes)
if err != nil {
return nil, err
}
result := make(map[string]string, len(indexes))
for _, idx := range indexes {
result[idx.Name] = idx.Sql
}
return result, nil
}
// DeleteTable drops the specified table.
//
// This method is a no-op if a table with the provided name doesn't exist.
//
// Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
func (dao *Dao) DeleteTable(tableName string) error {
_, err := dao.DB().DropTable(tableName).Execute()
_, err := dao.DB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}",
tableName,
)).Execute()
return err
}
+87 -13
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
@@ -23,17 +24,18 @@ func TestHasTable(t *testing.T) {
{"_admins", true},
{"demo3", true},
{"DEMO3", true}, // table names are case insensitives by default
{"view1", true}, // view
}
for i, scenario := range scenarios {
result := app.Dao().HasTable(scenario.tableName)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
}
}
}
func TestGetTableColumns(t *testing.T) {
func TestTableColumns(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -45,21 +47,50 @@ func TestGetTableColumns(t *testing.T) {
{"_params", []string{"id", "key", "value", "created", "updated"}},
}
for i, scenario := range scenarios {
columns, _ := app.Dao().GetTableColumns(scenario.tableName)
for i, s := range scenarios {
columns, _ := app.Dao().TableColumns(s.tableName)
if len(columns) != len(scenario.expected) {
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expected, columns)
if len(columns) != len(s.expected) {
t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
continue
}
for _, c := range columns {
if !list.ExistInSlice(c, scenario.expected) {
t.Errorf("(%d) Didn't expect column %s", i, c)
if !list.ExistInSlice(c, s.expected) {
t.Errorf("[%d] Didn't expect column %s", i, c)
}
}
}
}
func TestTableInfo(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
tableName string
expected string
}{
{"", "null"},
{"missing", "null"},
{
"_admins",
`[{"PK":1,"Index":0,"Name":"id","Type":"TEXT","NotNull":false,"DefaultValue":null},{"PK":0,"Index":1,"Name":"avatar","Type":"INTEGER","NotNull":true,"DefaultValue":0},{"PK":0,"Index":2,"Name":"email","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":3,"Name":"tokenKey","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":4,"Name":"passwordHash","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":5,"Name":"lastResetSentAt","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":6,"Name":"created","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":7,"Name":"updated","Type":"TEXT","NotNull":true,"DefaultValue":""}]`,
},
}
for i, s := range scenarios {
rows, _ := app.Dao().TableInfo(s.tableName)
raw, _ := json.Marshal(rows)
rawStr := string(raw)
if rawStr != s.expected {
t.Errorf("[%d] Expected \n%v, \ngot \n%v", i, s.expected, rawStr)
}
}
}
func TestDeleteTable(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -69,16 +100,17 @@ func TestDeleteTable(t *testing.T) {
expectError bool
}{
{"", true},
{"test", true},
{"test", false}, // missing tables are ignored
{"_admins", false},
{"demo3", false},
}
for i, scenario := range scenarios {
err := app.Dao().DeleteTable(scenario.tableName)
for i, s := range scenarios {
err := app.Dao().DeleteTable(s.tableName)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
if hasErr != s.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v", i, s.expectError, hasErr)
}
}
}
@@ -107,3 +139,45 @@ func TestVacuum(t *testing.T) {
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
}
}
func TestTableIndexes(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
table string
expectError bool
expectIndexes []string
}{
{
"missing",
false,
nil,
},
{
"demo2",
false,
[]string{"idx_demo2_created", "idx_unique_demo2_title", "idx_demo2_active"},
},
}
for _, s := range scenarios {
result, err := app.Dao().TableIndexes(s.table)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v", s.table, s.expectError, hasErr)
}
if len(s.expectIndexes) != len(result) {
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.table, len(s.expectIndexes), len(result), result)
continue
}
for _, name := range s.expectIndexes {
if result[name] == "" {
t.Errorf("[%s] Missing index %q in \n%v", s.table, name, result)
}
}
}
}
+627
View File
@@ -0,0 +1,627 @@
package daos
import (
"errors"
"fmt"
"io"
"regexp"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/tokenizer"
"github.com/pocketbase/pocketbase/tools/types"
)
// DeleteView drops the specified view name.
//
// This method is a no-op if a view with the provided name doesn't exist.
//
// Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input!
func (dao *Dao) DeleteView(name string) error {
_, err := dao.DB().NewQuery(fmt.Sprintf(
"DROP VIEW IF EXISTS {{%s}}",
name,
)).Execute()
return err
}
// SaveView creates (or updates already existing) persistent SQL view.
//
// Be aware that this method is vulnerable to SQL injection and the
// "selectQuery" argument must come only from trusted input!
func (dao *Dao) SaveView(name string, selectQuery string) error {
return dao.RunInTransaction(func(txDao *Dao) error {
// delete old view (if exists)
if err := txDao.DeleteView(name); err != nil {
return err
}
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
// try to eagerly detect multiple inline statements
tk := tokenizer.NewFromString(selectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
}
// (re)create the view
//
// 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, selectQuery)
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
return err
}
// fetch the view table info to ensure that the view was created
// because missing tables or columns won't return an error
if _, err := txDao.TableInfo(name); err != nil {
// manually cleanup previously created view in case the func
// is called in a nested transaction and the error is discarded
txDao.DeleteView(name)
return err
}
return nil
})
}
// CreateViewSchema creates a new view schema from the provided select query.
//
// There are some caveats:
// - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
result := schema.NewSchema()
suggestedFields, err := dao.parseQueryToFields(selectQuery)
if err != nil {
return result, err
}
// note wrap in a transaction in case the selectQuery contains
// multiple statements allowing us to rollback on any error
txErr := dao.RunInTransaction(func(txDao *Dao) error {
tempView := "_temp_" + security.PseudorandomString(5)
// create a temp view with the provided query
if err := txDao.SaveView(tempView, selectQuery); err != nil {
return err
}
defer txDao.DeleteView(tempView)
// extract the generated view table info
info, err := txDao.TableInfo(tempView)
if err != nil {
return err
}
var hasId bool
for _, row := range info {
if row.Name == schema.FieldNameId {
hasId = true
}
if list.ExistInSlice(row.Name, schema.BaseModelFieldNames()) {
continue // skip base model fields since they are not part of the schema
}
var field *schema.SchemaField
if f, ok := suggestedFields[row.Name]; ok {
field = f.field
} else {
field = defaultViewField(row.Name)
}
result.AddField(field)
}
if !hasId {
return errors.New("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
}
return nil
})
return result, txErr
}
// FindRecordByViewFile returns the original models.Record of the
// provided view collection file.
func (dao *Dao) FindRecordByViewFile(
viewCollectionNameOrId string,
fileFieldName string,
filename string,
) (*models.Record, error) {
view, err := dao.FindCollectionByNameOrId(viewCollectionNameOrId)
if err != nil {
return nil, err
}
if !view.IsView() {
return nil, errors.New("not a view collection")
}
var findFirstNonViewQueryFileField func(int) (*queryField, error)
findFirstNonViewQueryFileField = func(level int) (*queryField, error) {
// check the level depth to prevent infinite circular recursion
// (the limit is arbitrary and may change in the future)
if level > 5 {
return nil, errors.New("reached the max recursion level of view collection file field queries")
}
queryFields, err := dao.parseQueryToFields(view.ViewOptions().Query)
if err != nil {
return nil, err
}
for _, item := range queryFields {
if item.collection == nil ||
item.original == nil ||
item.field.Name != fileFieldName {
continue
}
if item.collection.IsView() {
view = item.collection
fileFieldName = item.original.Name
return findFirstNonViewQueryFileField(level + 1)
}
return item, nil
}
return nil, errors.New("no query file field found")
}
qf, err := findFirstNonViewQueryFileField(1)
if err != nil {
return nil, err
}
cleanFieldName := inflector.Columnify(qf.original.Name)
record := &models.Record{}
query := dao.RecordQuery(qf.collection).Limit(1)
if opt, ok := qf.original.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{cleanFieldName: filename})
} else {
query.InnerJoin(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{_je_file}}`,
cleanFieldName, cleanFieldName, cleanFieldName,
), dbx.HashExp{"_je_file.value": filename})
}
if err := query.One(record); err != nil {
return nil, err
}
return record, nil
}
// -------------------------------------------------------------------
// Raw query to schema helpers
// -------------------------------------------------------------------
type queryField struct {
// field is the final resolved field.
field *schema.SchemaField
// collection refers to the original field's collection model.
// It could be nil if the found query field is not from a collection schema.
collection *models.Collection
// original is the original found collection field.
// It could be nil if the found query field is not from a collection schema.
original *schema.SchemaField
}
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 {
return nil, err
}
collections, err := dao.findCollectionsByIdentifiers(p.tables)
if err != nil {
return nil, err
}
result := make(map[string]*queryField, len(p.columns))
var mainTable identifier
if len(p.tables) > 0 {
mainTable = p.tables[0]
}
for _, col := range p.columns {
colLower := strings.ToLower(col.original)
// numeric aggregations
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeNumber,
},
}
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
var collection *models.Collection
if len(parts) == 2 {
fieldName = parts[1]
collection = collections[parts[0]]
} else {
fieldName = parts[0]
collection = collections[mainTable.alias]
}
// fallback to the default field if the found column is not from a collection schema
if collection == nil {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
}
continue
}
if fieldName == "*" {
return nil, errors.New("dynamic column names are not supported")
}
// find the first field by name (case insensitive)
var field *schema.SchemaField
for _, f := range collection.Schema.Fields() {
if strings.EqualFold(f.Name, fieldName) {
field = f
break
}
}
if field != nil {
clone := *field
clone.Id = "" // unset to prevent duplications if the same field is aliased multiple times
clone.Name = col.alias
result[col.alias] = &queryField{
field: &clone,
collection: collection,
original: field,
}
continue
}
if fieldName == schema.FieldNameId {
// convert to relation since it is a direct id reference
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(1),
CollectionId: collection.Id,
},
},
collection: collection,
}
} else if fieldName == schema.FieldNameCreated || fieldName == schema.FieldNameUpdated {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeDate,
},
collection: collection,
}
} else if fieldName == schema.FieldNameUsername && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeText,
},
collection: collection,
}
} else if fieldName == schema.FieldNameEmail && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeEmail,
},
collection: collection,
}
} else if (fieldName == schema.FieldNameVerified || fieldName == schema.FieldNameEmailVisibility) && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeBool,
},
collection: collection,
}
} else {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
collection: collection,
}
}
}
return result, nil
}
func (dao *Dao) findCollectionsByIdentifiers(tables []identifier) (map[string]*models.Collection, error) {
names := make([]any, 0, len(tables))
for _, table := range tables {
if strings.Contains(table.alias, "(") {
continue // skip expressions
}
names = append(names, table.original)
}
if len(names) == 0 {
return nil, nil
}
result := make(map[string]*models.Collection, len(names))
collections := make([]*models.Collection, 0, len(names))
err := dao.CollectionQuery().
AndWhere(dbx.In("name", names...)).
All(&collections)
if err != nil {
return nil, err
}
for _, table := range tables {
for _, collection := range collections {
if collection.Name == table.original {
result[table.alias] = collection
}
}
}
return result, nil
}
// -------------------------------------------------------------------
// Raw query identifiers parser
// -------------------------------------------------------------------
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(inner join|outer join|left join|right join|join)\s+?`)
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group by|having|order|limit|with)\s+?`)
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
type identifier struct {
original string
alias string
}
type identifiersParser struct {
columns []identifier
tables []identifier
}
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t')
tk.KeepSeparator(true)
var skip bool
var partType string
var activeBuilder *strings.Builder
var selectParts strings.Builder
var fromParts strings.Builder
var joinParts strings.Builder
for {
token, err := tk.Scan()
if err != nil {
if err != io.EOF {
return err
}
break
}
trimmed := strings.ToLower(strings.TrimSpace(token))
switch trimmed {
case "select":
skip = false
partType = "select"
activeBuilder = &selectParts
case "distinct":
continue // ignore as it is not important for the identifiers parsing
case "from":
skip = false
partType = "from"
activeBuilder = &fromParts
case "_join_":
skip = false
// the previous part was also a join
if partType == "join" {
joinParts.WriteString(",")
}
partType = "join"
activeBuilder = &joinParts
case "_discard_":
// skip following tokens
skip = true
default:
isJoin := partType == "join"
if isJoin && trimmed == "on" {
skip = true
}
if !skip && activeBuilder != nil {
activeBuilder.WriteString(" ")
activeBuilder.WriteString(token)
}
}
}
selects, err := extractIdentifiers(selectParts.String())
if err != nil {
return err
}
froms, err := extractIdentifiers(fromParts.String())
if err != nil {
return err
}
joins, err := extractIdentifiers(joinParts.String())
if err != nil {
return err
}
p.columns = selects
p.tables = froms
p.tables = append(p.tables, joins...)
return nil
}
func extractIdentifiers(rawExpression string) ([]identifier, error) {
rawTk := tokenizer.NewFromString(rawExpression)
rawTk.Separators(',')
rawIdentifiers, err := rawTk.ScanAll()
if err != nil {
return nil, err
}
result := make([]identifier, 0, len(rawIdentifiers))
for _, rawIdentifier := range rawIdentifiers {
tk := tokenizer.NewFromString(rawIdentifier)
tk.Separators(' ', '\n', '\t')
parts, err := tk.ScanAll()
if err != nil {
return nil, err
}
resolved, err := identifierFromParts(parts)
if err != nil {
return nil, err
}
result = append(result, resolved)
}
return result, nil
}
func identifierFromParts(parts []string) (identifier, error) {
var result identifier
switch len(parts) {
case 3:
if !strings.EqualFold(parts[1], "as") {
return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
}
result.original = parts[0]
result.alias = parts[2]
case 2:
result.original = parts[0]
result.alias = parts[1]
case 1:
subParts := strings.Split(parts[0], ".")
result.original = parts[0]
result.alias = subParts[len(subParts)-1]
default:
return result, fmt.Errorf(`invalid identifier parts %v`, parts)
}
result.original = trimRawIdentifier(result.original)
// 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, extraTrimChars ...string) string {
trimChars := "`\"[];"
if len(extraTrimChars) > 0 {
trimChars += strings.Join(extraTrimChars, "")
}
parts := strings.Split(rawIdentifier, ".")
for i := range parts {
parts[i] = strings.Trim(parts[i], trimChars)
}
return strings.Join(parts, ".")
}
+619
View File
@@ -0,0 +1,619 @@
package daos_test
import (
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func ensureNoTempViews(app core.App, t *testing.T) {
var total int
err := app.Dao().DB().Select("count(*)").
From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "view"}).
AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)).
Limit(1).
Row(&total)
if err != nil {
t.Fatalf("Failed to check for temp views: %v", err)
}
if total > 0 {
t.Fatalf("Expected all temp views to be deleted, got %d", total)
}
}
func TestDeleteView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
viewName string
expectError bool
}{
{"", true},
{"demo1", true}, // not a view table
{"missing", false}, // missing or already deleted
{"view1", false}, // existing
{"VieW1", false}, // view names are case insensitives
}
for i, s := range scenarios {
err := app.Dao().DeleteView(s.viewName)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
}
}
ensureNoTempViews(app, t)
}
func TestSaveView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
scenarioName string
viewName string
query string
expectError bool
expectColumns []string
}{
{
"empty name and query",
"",
"",
true,
nil,
},
{
"empty name",
"",
"select * from _admins",
true,
nil,
},
{
"empty query",
"123Test",
"",
true,
nil,
},
{
"invalid query",
"123Test",
"123 456",
true,
nil,
},
{
"missing table",
"123Test",
"select id from missing",
true,
nil,
},
{
"non select query",
"123Test",
"drop table _admins",
true,
nil,
},
{
"multiple select queries",
"123Test",
"select *, count(id) as c from _admins; select * from demo1;",
true,
nil,
},
{
"try to break the parent parenthesis",
"123Test",
"select *, count(id) as c from `_admins`)",
true,
nil,
},
{
"simple select query (+ trimmed semicolon)",
"123Test",
";select *, count(id) as c from _admins;",
false,
[]string{
"id", "created", "updated",
"passwordHash", "tokenKey", "email",
"lastResetSentAt", "avatar", "c",
},
},
{
"update old view with new query",
"123Test",
"select 1 as test from _admins",
false,
[]string{"test"},
},
}
for _, s := range scenarios {
t.Run(s.scenarioName, func(t *testing.T) {
err := app.Dao().SaveView(s.viewName, s.query)
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)
}
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
_, err := txDao.CreateViewSchema("select id from missing")
if err == nil {
t.Fatal("Expected error, got nil")
}
return nil
})
ensureNoTempViews(app, t)
}
func TestCreateViewSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
query string
expectError bool
expectFields map[string]string // name-type pairs
}{
{
"empty query",
"",
true,
nil,
},
{
"invalid query",
"test 123456",
true,
nil,
},
{
"missing table",
"select id from missing",
true,
nil,
},
{
"query with wildcard column",
"select a.id, a.* from demo1 a",
true,
nil,
},
{
"query without id",
"select text, url, created, updated from demo1",
true,
nil,
},
{
"query with comments",
`
select
-- test single line
id,
text,
/* multi
line comment */
url, created, updated from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"url": schema.FieldTypeUrl,
},
},
{
"query with all fields and quoted identifiers",
`
select
"id",
"created",
"updated",
[text],
` + "`bool`" + `,
"url",
"select_one",
"select_many",
"file_one",
"demo1"."file_many",
` + "`demo1`." + "`number`" + ` number_alias,
"email",
"datetime",
"json",
"rel_one",
"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,
"single_quoted_column": schema.FieldTypeJson,
},
},
{
"query with indirect relations fields",
"select a.id, b.id as bid, b.created from demo1 as a left join demo2 b",
false,
map[string]string{
"bid": schema.FieldTypeRelation,
},
},
{
"query with multiple froms, joins and style of aliasses",
`
select
a.id as id,
b.id as bid,
lj.id cid,
ij.id as did,
a.bool,
_admins.id as eid,
_admins.email
from demo1 a, demo2 as b
left join demo3 lj on lj.id = 123
inner join demo4 as ij on ij.id = 123
join _admins
where 1=1
group by a.id
limit 10
`,
false,
map[string]string{
"bid": schema.FieldTypeRelation,
"cid": schema.FieldTypeRelation,
"did": schema.FieldTypeRelation,
"bool": schema.FieldTypeBool,
"eid": schema.FieldTypeJson, // not from collection
"email": schema.FieldTypeJson, // not from collection
},
},
{
"query with casts",
`select
a.id,
count(a.id) count,
cast(a.id as int) cast_int,
cast(a.id as integer) cast_integer,
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,
min(a.id) min,
max(a.id) max
from demo1 a`,
false,
map[string]string{
"count": schema.FieldTypeNumber,
"total": schema.FieldTypeNumber,
"cast_int": schema.FieldTypeNumber,
"cast_integer": schema.FieldTypeNumber,
"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,
"min": schema.FieldTypeJson,
"max": schema.FieldTypeJson,
},
},
{
"query with reserved auth collection fields",
`
select
a.id,
a.username,
a.email,
a.emailVisibility,
a.verified,
demo1.id relid
from users a
left join demo1
`,
false,
map[string]string{
"username": schema.FieldTypeText,
"email": schema.FieldTypeEmail,
"emailVisibility": schema.FieldTypeBool,
"verified": schema.FieldTypeBool,
"relid": schema.FieldTypeRelation,
},
},
{
"query with unknown fields and aliases",
`select
id,
id as id2,
text as text_alias,
url as url_alias,
"demo1"."bool" as bool_alias,
number as number_alias,
created created_alias,
updated updated_alias,
123 as custom
from demo1`,
false,
map[string]string{
"id2": schema.FieldTypeRelation,
"text_alias": schema.FieldTypeText,
"url_alias": schema.FieldTypeUrl,
"bool_alias": schema.FieldTypeBool,
"number_alias": schema.FieldTypeNumber,
"created_alias": schema.FieldTypeDate,
"updated_alias": schema.FieldTypeDate,
"custom": schema.FieldTypeJson,
},
},
{
"query with distinct and reordered id column",
`select distinct
id as id2,
id,
123 as custom
from demo1`,
false,
map[string]string{
"id2": schema.FieldTypeRelation,
"custom": schema.FieldTypeJson,
},
},
{
"query with aliasing the same field multiple times",
`select
a.id as id,
a.text as alias1,
a.text as alias2,
b.text as alias3,
b.text as alias4
from demo1 a
left join demo1 as b`,
false,
map[string]string{
"alias1": schema.FieldTypeText,
"alias2": schema.FieldTypeText,
"alias3": schema.FieldTypeText,
"alias4": schema.FieldTypeText,
},
},
}
for _, s := range scenarios {
result, err := app.Dao().CreateViewSchema(s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if len(s.expectFields) != len(result.Fields()) {
serialized, _ := json.Marshal(result)
t.Errorf("[%s] Expected %d fields, got %d: \n%s", s.name, len(s.expectFields), len(result.Fields()), serialized)
continue
}
for name, typ := range s.expectFields {
field := result.GetFieldByName(name)
if field == nil {
t.Errorf("[%s] Expected to find field %s, got nil", s.name, name)
continue
}
if field.Type != typ {
t.Errorf("[%s] Expected field %s to be %q, got %s", s.name, name, typ, field.Type)
continue
}
}
}
ensureNoTempViews(app, t)
}
func TestFindRecordByViewFile(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
prevCollection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
totalLevels := 6
// create collection view mocks
fileOneAlias := "file_one one0"
fileManyAlias := "file_many many0"
mockCollections := make([]*models.Collection, 0, totalLevels)
for i := 0; i <= totalLevels; i++ {
view := new(models.Collection)
view.Type = models.CollectionTypeView
view.Name = fmt.Sprintf("_test_view%d", i)
view.SetOptions(&models.CollectionViewOptions{
Query: fmt.Sprintf(
"select id, %s, %s from %s",
fileOneAlias,
fileManyAlias,
prevCollection.Name,
),
})
// save view
if err := app.Dao().SaveCollection(view); err != nil {
t.Fatalf("Failed to save view%d: %v", i, err)
}
mockCollections = append(mockCollections, view)
prevCollection = view
fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1)
fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1)
}
fileOneName := "test_d61b33QdDU.txt"
fileManyName := "test_QZFjKjXchk.txt"
expectedRecordId := "84nmscqy84lsi1t"
scenarios := []struct {
name string
collectionNameOrId string
fileFieldName string
filename string
expectError bool
expectRecordId string
}{
{
"missing collection",
"missing",
"a",
fileOneName,
true,
"",
},
{
"non-view collection",
"demo1",
"file_one",
fileOneName,
true,
"",
},
{
"view collection after the max recursion limit",
mockCollections[totalLevels-1].Name,
fmt.Sprintf("one%d", totalLevels-1),
fileOneName,
true,
"",
},
{
"first view collection (single file)",
mockCollections[0].Name,
"one0",
fileOneName,
false,
expectedRecordId,
},
{
"first view collection (many files)",
mockCollections[0].Name,
"many0",
fileManyName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (single file)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("one%d", totalLevels-2),
fileOneName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (many files)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("many%d", totalLevels-2),
fileManyName,
false,
expectedRecordId,
},
}
for _, s := range scenarios {
record, err := app.Dao().FindRecordByViewFile(
s.collectionNameOrId,
s.fileFieldName,
s.filename,
)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if record.Id != s.expectRecordId {
t.Errorf("[%s] Expected recordId %q, got %q", s.name, s.expectRecordId, record.Id)
}
}
}
+50 -4
View File
@@ -5,10 +5,12 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/ghupdate"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
)
@@ -20,6 +22,30 @@ func main() {
// Optional plugin flags:
// ---------------------------------------------------------------
var hooksDir string
app.RootCmd.PersistentFlags().StringVar(
&hooksDir,
"hooksDir",
"",
"the directory with the JS app hooks",
)
var hooksWatch bool
app.RootCmd.PersistentFlags().BoolVar(
&hooksWatch,
"hooksWatch",
true,
"auto restart the app on pb_hooks file change",
)
var hooksPool int
app.RootCmd.PersistentFlags().IntVar(
&hooksPool,
"hooksPool",
25,
"the total prewarm goja.Runtime instances for the JS app hooks execution",
)
var migrationsDir string
app.RootCmd.PersistentFlags().StringVar(
&migrationsDir,
@@ -52,24 +78,43 @@ func main() {
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
)
var queryTimeout int
app.RootCmd.PersistentFlags().IntVar(
&queryTimeout,
"queryTimeout",
30,
"the default SELECT queries timeout in seconds",
)
app.RootCmd.ParseFlags(os.Args[1:])
// ---------------------------------------------------------------
// Plugins and hooks:
// ---------------------------------------------------------------
// load js pb_migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
Dir: migrationsDir,
// load jsvm (hooks and migrations)
jsvm.MustRegister(app, jsvm.Config{
MigrationsDir: migrationsDir,
HooksDir: hooksDir,
HooksWatch: hooksWatch,
HooksPoolSize: hooksPool,
})
// migrate command (with js templates)
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
// GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
app.OnAfterBootstrap().PreAdd(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
return nil
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// serves static files from the provided public dir (if exists)
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
@@ -87,5 +132,6 @@ func defaultPublicDir() string {
// most likely ran with go run
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}
+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")
}
}
+87
View File
@@ -0,0 +1,87 @@
package forms
import (
"regexp"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/core"
)
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
// AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
//
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct {
app core.App
// ClientId is the identifier of your app (aka. Service ID).
ClientId string `form:"clientId" json:"clientId"`
// TeamId is a 10-character string associated with your developer account
// (usually could be found next to your name in the Apple Developer site).
TeamId string `form:"teamId" json:"teamId"`
// KeyId is a 10-character key identifier generated for the "Sign in with Apple"
// private key associated with your developer account.
KeyId string `form:"keyId" json:"keyId"`
// PrivateKey is the private key associated to your app.
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"`
// Duration specifies how long the generated JWT should be considered valid.
// The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"`
}
// NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
// config created from the provided [core.App] instances.
func NewAppleClientSecretCreate(app core.App) *AppleClientSecretCreate {
form := &AppleClientSecretCreate{
app: app,
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AppleClientSecretCreate) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required),
validation.Field(&form.TeamId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.KeyId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.PrivateKey, validation.Required, validation.Match(privateKeyRegex)),
validation.Field(&form.Duration, validation.Required, validation.Min(1), validation.Max(15777000)),
)
}
// Submit validates the form and returns a new Apple Client Secret JWT.
func (form *AppleClientSecretCreate) Submit() (string, error) {
if err := form.Validate(); err != nil {
return "", err
}
signKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(strings.TrimSpace(form.PrivateKey)))
if err != nil {
return "", err
}
now := time.Now()
claims := &jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
Subject: form.ClientId,
Issuer: form.TeamId,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(form.Duration) * time.Second)),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = form.KeyId
return token.SignedString(signKey)
}
+117
View File
@@ -0,0 +1,117 @@
package forms_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedKey,
},
)
scenarios := []struct {
name string
formData map[string]any
expectError bool
}{
{
"empty data",
map[string]any{},
true,
},
{
"invalid data",
map[string]any{
"clientId": "",
"teamId": "123456789",
"keyId": "123456789",
"privateKey": "-----BEGIN PRIVATE KEY----- invalid -----END PRIVATE KEY-----",
"duration": -1,
},
true,
},
{
"valid data",
map[string]any{
"clientId": "123",
"teamId": "1234567890",
"keyId": "1234567891",
"privateKey": string(privatePem),
"duration": 1,
},
false,
},
}
for _, s := range scenarios {
form := forms.NewAppleClientSecretCreate(app)
rawData, marshalErr := json.Marshal(s.formData)
if marshalErr != nil {
t.Errorf("[%s] Failed to marshalize the scenario data: %v", s.name, marshalErr)
continue
}
// load data
loadErr := json.Unmarshal(rawData, form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
secret, err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
if secret == "" {
t.Errorf("[%s] Expected non-empty secret", s.name)
}
claims := jwt.MapClaims{}
token, _, err := jwt.NewParser().ParseUnverified(secret, claims)
if err != nil {
t.Errorf("[%s] Failed to parse token: %v", s.name, err)
}
if alg := token.Header["alg"]; alg != "ES256" {
t.Errorf("[%s] Expected %q alg header, got %q", s.name, "ES256", alg)
}
if kid := token.Header["kid"]; kid != form.KeyId {
t.Errorf("[%s] Expected %q kid header, got %q", s.name, form.KeyId, kid)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package forms
import (
"context"
"regexp"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
)
var backupNameRegex = regexp.MustCompile(`^[a-z0-9_-]+\.zip$`)
// BackupCreate is a request form for creating a new app backup.
type BackupCreate struct {
app core.App
ctx context.Context
Name string `form:"name" json:"name"`
}
// NewBackupCreate creates new BackupCreate request form.
func NewBackupCreate(app core.App) *BackupCreate {
return &BackupCreate{
app: app,
ctx: context.Background(),
}
}
// SetContext replaces the default form context with the provided one.
func (form *BackupCreate) SetContext(ctx context.Context) {
form.ctx = ctx
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *BackupCreate) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Name,
validation.Length(1, 100),
validation.Match(backupNameRegex),
validation.By(form.checkUniqueName),
),
)
}
func (form *BackupCreate) checkUniqueName(value any) error {
v, _ := value.(string)
if v == "" {
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); err != nil || exists {
return validation.NewError("validation_backup_name_exists", "The backup file name is invalid or already exists.")
}
return nil
}
// Submit validates the form and creates the app backup.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before creating the backup.
func (form *BackupCreate) Submit(interceptors ...InterceptorFunc[string]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(form.Name, func(name string) error {
return form.app.CreateBackup(form.ctx, name)
}, interceptors...)
}
+102
View File
@@ -0,0 +1,102 @@
package forms_test
import (
"strings"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestBackupCreateValidateAndSubmit(t *testing.T) {
scenarios := []struct {
name string
backupName string
expectedErrors []string
}{
{
"invalid length",
strings.Repeat("a", 97) + ".zip",
[]string{"name"},
},
{
"valid length + invalid format",
strings.Repeat("a", 96),
[]string{"name"},
},
{
"valid length + valid format",
strings.Repeat("a", 96) + ".zip",
[]string{},
},
{
"auto generated name",
"",
[]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()
form := forms.NewBackupCreate(app)
form.Name = s.backupName
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
return
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
// retrieve all created backup files
files, err := fsys.List("")
if err != nil {
t.Fatal("Failed to retrieve backup files")
return
}
if result != nil {
if total := len(files); total != 0 {
t.Fatalf("Didn't expected backup files, found %d", total)
}
return
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 backup file, got %d", total)
return
}
if s.backupName == "" {
prefix := "pb_backup_"
if !strings.HasPrefix(files[0].Key, prefix) {
t.Fatalf("Expected the backup file, to have prefix %q: %q", prefix, files[0].Key)
}
} else if 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)
}
}
})
}
}
+178 -65
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
"regexp"
"strings"
"strconv"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
@@ -13,6 +13,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/types"
@@ -26,17 +27,18 @@ type CollectionUpsert struct {
dao *daos.Dao
collection *models.Collection
Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
ListRule *string `form:"listRule" json:"listRule"`
ViewRule *string `form:"viewRule" json:"viewRule"`
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
Options types.JsonMap `form:"options" json:"options"`
Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
Indexes types.JsonArray[string] `form:"indexes" json:"indexes"`
ListRule *string `form:"listRule" json:"listRule"`
ViewRule *string `form:"viewRule" json:"viewRule"`
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
Options types.JsonMap `form:"options" json:"options"`
}
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
@@ -57,6 +59,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
form.Type = form.collection.Type
form.Name = form.collection.Name
form.System = form.collection.System
form.Indexes = form.collection.Indexes
form.ListRule = form.collection.ListRule
form.ViewRule = form.collection.ViewRule
form.CreateRule = form.collection.CreateRule
@@ -69,7 +72,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
}
clone, _ := form.collection.Schema.Clone()
if clone != nil {
if clone != nil && form.Type != models.CollectionTypeView {
form.Schema = *clone
} else {
form.Schema = schema.Schema{}
@@ -86,6 +89,16 @@ func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error {
isAuth := form.Type == models.CollectionTypeAuth
isView := form.Type == models.CollectionTypeView
// generate schema from the query (overwriting any explicit user defined schema)
if isView {
options := models.CollectionViewOptions{}
if err := decodeOptions(form.Options, &options); err != nil {
return err
}
form.Schema, _ = form.dao.CreateViewSchema(options.Query)
}
return validation.ValidateStruct(form,
validation.Field(
@@ -104,7 +117,11 @@ func (form *CollectionUpsert) Validate() error {
validation.Field(
&form.Type,
validation.Required,
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
validation.In(
models.CollectionTypeBase,
models.CollectionTypeAuth,
models.CollectionTypeView,
),
validation.By(form.ensureNoTypeChange),
),
validation.Field(
@@ -115,23 +132,33 @@ func (form *CollectionUpsert) Validate() error {
validation.By(form.ensureNoSystemNameChange),
validation.By(form.checkUniqueName),
),
// validates using the type's own validation rules + some collection's specific
// validates using the type's own validation rules + some collection's specifics
validation.Field(
&form.Schema,
validation.By(form.checkMinSchemaFields),
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.checkRelationFields),
validation.When(
isAuth,
validation.By(form.ensureNoAuthFieldName),
),
validation.When(isAuth, validation.By(form.ensureNoAuthFieldName)),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
validation.Field(
&form.CreateRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.UpdateRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.DeleteRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(&form.Indexes, validation.By(form.checkIndexes)),
validation.Field(&form.Options, validation.By(form.checkOptions)),
)
}
@@ -149,11 +176,6 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
}
// ensure that there is no existing table name with the same name
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
return nil
}
@@ -207,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
@@ -222,13 +236,34 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
options, _ := field.Options.(*schema.RelationOptions)
if options == nil {
continue
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.NewError(
"validation_schema_invalid_relation_field_options",
"The relation field has invalid field options.",
)},
}
}
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
// prevent collectionId change
oldField := form.collection.Schema.GetFieldById(field.Id)
if oldField != nil {
oldOptions, _ := oldField.Options.(*schema.RelationOptions)
if oldOptions != nil && oldOptions.CollectionId != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_relation_change",
"The relation collection cannot be changed.",
),
}},
}
}
}
relCollection, _ := form.dao.FindCollectionByNameOrId(options.CollectionId)
// validate collectionId
if err != nil || collection.Id != options.CollectionId {
if relCollection == nil || relCollection.Id != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
@@ -239,17 +274,16 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
}
}
// validate displayFields (if any)
for _, name := range options.DisplayFields {
if collection.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, collection.Name),
),
}},
}
// allow only views to have relations to other views
// (see https://github.com/pocketbase/pocketbase/issues/3000)
if form.Type != models.CollectionTypeView && relCollection.IsView() {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_non_view_base_relation_collection",
"Non view collections are not allowed to have a view relation.",
),
}},
}
}
}
@@ -288,13 +322,15 @@ func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
}
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
if form.Type == models.CollectionTypeAuth {
return nil // auth collections doesn't require having additional schema fields
}
v, _ := value.(schema.Schema)
v, ok := value.(schema.Schema)
if !ok || len(v.Fields()) == 0 {
return validation.ErrRequired
switch form.Type {
case models.CollectionTypeAuth, models.CollectionTypeView:
return nil // no schema fields constraint
default:
if len(v.Fields()) == 0 {
return validation.ErrRequired
}
}
return nil
@@ -334,7 +370,45 @@ 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
}
func (form *CollectionUpsert) checkIndexes(value any) error {
v, _ := value.(types.JsonArray[string])
if form.Type == models.CollectionTypeView && len(v) > 0 {
return validation.NewError(
"validation_indexes_not_supported",
"The collection doesn't support indexes.",
)
}
for i, rawIndex := range v {
parsed := dbutils.ParseIndex(rawIndex)
if !parsed.IsValid() {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_invalid_index_expression",
"Invalid CREATE INDEX expression.",
),
}
}
// note: we don't check the index table because it is always
// overwritten by the daos.SyncRecordTableSchema to allow
// easier partial modifications (eg. changing only the collection name).
// if !strings.EqualFold(parsed.TableName, form.Name) {
// return validation.Errors{
// strconv.Itoa(i): validation.NewError(
// "validation_invalid_index_table",
// fmt.Sprintf("The index table must be the same as the collection name."),
// ),
// }
// }
}
return nil
@@ -343,15 +417,11 @@ func (form *CollectionUpsert) checkRule(value any) error {
func (form *CollectionUpsert) checkOptions(value any) error {
v, _ := value.(types.JsonMap)
if form.Type == models.CollectionTypeAuth {
raw, err := v.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
switch form.Type {
case models.CollectionTypeAuth:
options := models.CollectionAuthOptions{}
if err := json.Unmarshal(raw, &options); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
if err := decodeOptions(v, &options); err != nil {
return err
}
// check the generic validations
@@ -363,6 +433,39 @@ func (form *CollectionUpsert) checkOptions(value any) error {
if err := form.checkRule(options.ManageRule); err != nil {
return validation.Errors{"manageRule": err}
}
case models.CollectionTypeView:
options := models.CollectionViewOptions{}
if err := decodeOptions(v, &options); err != nil {
return err
}
// check the generic validations
if err := options.Validate(); err != nil {
return err
}
// check the query option
if _, err := form.dao.CreateViewSchema(options.Query); err != nil {
return validation.Errors{
"query": validation.NewError(
"validation_invalid_view_query",
fmt.Sprintf("Invalid query - %s", err.Error()),
),
}
}
}
return nil
}
func decodeOptions(options types.JsonMap, result any) error {
raw, err := options.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
if err := json.Unmarshal(raw, result); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
return nil
@@ -398,7 +501,17 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Col
form.collection.Name = form.Name
}
form.collection.Schema = form.Schema
// view schema is autogenerated on save and cannot have indexes
if !form.collection.IsView() {
form.collection.Schema = form.Schema
// normalize indexes format
form.collection.Indexes = make(types.JsonArray[string], len(form.Indexes))
for i, rawIdx := range form.Indexes {
form.collection.Indexes[i] = dbutils.ParseIndex(rawIdx).Build()
}
}
form.collection.ListRule = form.ListRule
form.collection.ViewRule = form.ViewRule
form.collection.CreateRule = form.CreateRule
+304 -113
View File
@@ -10,6 +10,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
@@ -22,7 +23,7 @@ func TestNewCollectionUpsert(t *testing.T) {
collection.Name = "test_name"
collection.Type = "test_type"
collection.System = true
listRule := "testview"
listRule := "test_list"
collection.ListRule = &listRule
viewRule := "test_view"
collection.ViewRule = &viewRule
@@ -98,6 +99,7 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}{
{"empty create (base)", "", "{}", []string{"name", "schema"}},
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
{"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
{"empty update", "demo2", "{}", []string{}},
{
"create failure",
@@ -113,9 +115,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": "missing = '123'",
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'"
"deleteRule": "missing = '123'",
"indexes": ["create index '' on '' ()"]
}`,
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
},
{
"create failure - existing name",
@@ -169,26 +172,7 @@ 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 type options validators",
"create failure - check auth options validators",
"",
`{
"name": "test_new",
@@ -200,6 +184,16 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"options"},
},
{
"create failure - check view options validators",
"",
`{
"name": "test_new",
"type": "view",
"options": { "query": "invalid query" }
}`,
[]string{"options"},
},
{
"create success",
"",
@@ -223,7 +217,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": "test1='123' && emailVisibility = true",
"createRule": "test1='123' && email != ''",
"updateRule": "test1='123' && username != ''",
"deleteRule": "test1='123' && id != ''"
"deleteRule": "test1='123' && id != ''",
"indexes": ["create index idx_test_new on anything (test1)"]
}`,
[]string{},
},
@@ -234,9 +229,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"schema": [
{"id":"a123456","name":"test1","type":"url"},
{"id":"b123456","name":"test2","type":"bool"}
]
],
"indexes": ["create index idx_test_new on test_new (test1)", "invalid"]
}`,
[]string{"schema"},
[]string{"schema", "indexes"},
},
{
"update success - rename fields to existing field names (aka. reusing field names)",
@@ -280,6 +276,26 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"type"},
},
{
"update failure - changing relation collection",
"users",
`{
"schema": [
{
"id": "lkeigvv3",
"name": "rel",
"type": "relation",
"options": {
"collectionId": "wzlqyes4orhoygb",
"cascadeDelete": false,
"maxSelect": 1,
"displayFields": null
}
}
]
}`,
[]string{"schema"},
},
{
"update failure - all fields",
"demo2",
@@ -295,9 +311,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'",
"options": {"test": 123}
"options": {"test": 123},
"indexes": ["create index '' from demo2 on (id)"]
}`,
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
},
{
"update success - update all fields",
@@ -313,7 +330,11 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"createRule": "test='123' && email != ''",
"updateRule": "test='123' && username != ''",
"deleteRule": "test='123' && id != ''",
"options": {"minPasswordLength": 10}
"options": {"minPasswordLength": 10},
"indexes": [
"create index idx_clients_test1 on anything (id, email, test)",
"create unique index idx_clients_test2 on clients (id, username, email)"
]
}`,
[]string{},
},
@@ -340,7 +361,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null
"deleteRule": null,
"indexes": []
}`,
[]string{},
},
@@ -356,109 +378,278 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{},
},
// view tests
// -----------------------------------------------------------
{
"base->view relation",
"",
`{
"name": "test_view_relation",
"type": "base",
"schema": [
{
"name": "test",
"type": "relation",
"options":{
"collectionId": "v9gwnfh02gjq1q0"
}
}
]
}`,
[]string{"schema"}, // not allowed
},
{
"auth->view relation",
"",
`{
"name": "test_view_relation",
"type": "auth",
"schema": [
{
"name": "test",
"type": "relation",
"options": {
"collectionId": "v9gwnfh02gjq1q0"
}
}
]
}`,
[]string{"schema"}, // not allowed
},
{
"view->view relation",
"",
`{
"name": "test_view_relation",
"type": "view",
"options": {
"query": "select view1.id, view1.id as rel from view1"
}
}`,
[]string{}, // allowed
},
{
"view create failure",
"",
`{
"name": "upsert_view",
"type": "view",
"listRule": "id='123' && verified = true",
"viewRule": "id='123' && emailVisibility = true",
"schema": [
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
],
"options": {
"query": "select id, email from users; drop table _admins;"
},
"indexes": ["create index idx_test_view on upsert_view (id)"]
}`,
[]string{
"listRule",
"viewRule",
"options",
"indexes", // views don't have indexes
},
},
{
"view create success",
"",
`{
"name": "upsert_view",
"type": "view",
"listRule": "id='123' && verified = true",
"viewRule": "id='123' && emailVisibility = true",
"schema": [
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
],
"options": {
"query": "select id, emailVisibility, verified from users"
}
}`,
[]string{
// "schema", should be overwritten by an autogenerated from the query
},
},
{
"view update failure (schema autogeneration and rule fields check)",
"upsert_view",
`{
"name": "upsert_view_2",
"listRule": "id='456' && verified = true",
"viewRule": "id='456'",
"createRule": "id='123'",
"updateRule": "id='123'",
"deleteRule": "id='123'",
"schema": [
{"id":"abc123","name":"verified","type":"bool"}
],
"options": {
"query": "select 1 as id"
}
}`,
[]string{
"listRule", // missing field (ignoring the old or explicit schema)
"createRule", // not allowed
"updateRule", // not allowed
"deleteRule", // not allowed
},
},
{
"view update failure (check query identifiers format)",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as [invalid!@#]"
}
}`,
[]string{
"schema", // should fail due to invalid field name
},
},
{
"view update success",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as valid"
}
}`,
[]string{},
},
}
for _, s := range scenarios {
collection := &models.Collection{}
if s.existingName != "" {
var err error
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
if err != nil {
t.Fatal(err)
t.Run(s.testName, func(t *testing.T) {
collection := &models.Collection{}
if s.existingName != "" {
var err error
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
if err != nil {
t.Fatal(err)
}
}
}
form := forms.NewCollectionUpsert(app, collection)
form := forms.NewCollectionUpsert(app, collection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
continue
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCalls++
return next(c)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Fatalf("Failed to load form data: %v", loadErr)
}
}
// parse errors
result := form.Submit(interceptor)
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
continue
}
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCalls++
return next(c)
}
}
}
if len(s.expectedErrors) > 0 {
continue
}
// parse errors
result := form.Submit(interceptor)
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
}
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
if collection == nil {
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
continue
}
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Fatalf("Expected interceptor to be called %d, got %d", expectInterceptorCalls, interceptorCalls)
}
if form.Name != collection.Name {
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
}
// 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)
}
}
if form.Type != collection.Type {
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
}
if len(s.expectedErrors) > 0 {
return
}
if form.System != collection.System {
t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
}
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
if collection == nil {
t.Fatalf("Expected to find collection %q, got nil", form.Name)
}
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
}
if form.Name != collection.Name {
t.Fatalf("Expected Name %q, got %q", collection.Name, form.Name)
}
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
}
if form.Type != collection.Type {
t.Fatalf("Expected Type %q, got %q", collection.Type, form.Type)
}
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
}
if form.System != collection.System {
t.Fatalf("Expected System %v, got %v", collection.System, form.System)
}
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
}
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
t.Fatalf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
}
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
}
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
t.Fatalf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
}
formSchema, _ := form.Schema.MarshalJSON()
collectionSchema, _ := collection.Schema.MarshalJSON()
if string(formSchema) != string(collectionSchema) {
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
}
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
t.Fatalf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
}
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
t.Fatalf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
}
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
t.Fatalf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
}
rawFormSchema, _ := form.Schema.MarshalJSON()
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
t.Fatalf("Expected Schema \n%v, \ngot \n%v", string(rawCollectionSchema), string(rawFormSchema))
}
for _, f := range form.Schema.Fields() {
if collection.Schema.GetFieldByName(f.Name) == nil {
t.Fatalf("Missing field %s \nin \n%v", f.Name, string(rawFormSchema))
}
}
// check indexes (if any)
allIndexes, _ := app.Dao().TableIndexes(form.Name)
for _, formIdx := range form.Indexes {
parsed := dbutils.ParseIndex(formIdx)
parsed.TableName = form.Name
normalizedIdx := parsed.Build()
var exists bool
for _, idx := range allIndexes {
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
exists = true
continue
}
}
if !exists {
t.Fatalf("Missing index %s \nin \n%v", normalizedIdx, allIndexes)
}
}
})
}
}
+4 -8
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"
@@ -66,7 +65,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
importErr := txDao.ImportCollections(
collections,
form.DeleteMissing,
form.beforeRecordsSync,
form.afterSync,
)
if importErr == nil {
return nil
@@ -78,21 +77,18 @@ 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...)
}
func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
func (form *CollectionsImport) afterSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
// refresh the actual persisted collections list
refreshedCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&refreshedCollections); err != nil {
if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&refreshedCollections); err != nil {
return err
}
+117 -46
View File
@@ -38,6 +38,8 @@ func TestCollectionsImportValidate(t *testing.T) {
}
func TestCollectionsImportSubmit(t *testing.T) {
totalCollections := 11
scenarios := []struct {
name string
jsonData string
@@ -52,7 +54,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": []
}`,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
expectEvents: nil,
},
{
@@ -82,7 +84,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
@@ -101,7 +103,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
@@ -137,7 +139,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
expectCollectionsCount: 11,
expectCollectionsCount: totalCollections + 3,
expectEvents: map[string]int{
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
@@ -160,7 +162,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
@@ -202,9 +204,9 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: true,
expectCollectionsCount: 8,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 5,
"OnModelBeforeDelete": 1,
},
},
{
@@ -213,7 +215,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": [
{
"id":"sz5l5z67tg7gku0",
"name":"demo2",
"name":"demo2_rename",
"schema":[
{
"id":"_2hlxbmp",
@@ -253,7 +255,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
]
}`,
expectError: false,
expectCollectionsCount: 10,
expectCollectionsCount: totalCollections + 2,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
@@ -341,51 +343,120 @@ func TestCollectionsImportSubmit(t *testing.T) {
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnModelBeforeDelete": 6,
"OnModelAfterDelete": 6,
"OnModelBeforeDelete": totalCollections - 2,
"OnModelAfterDelete": totalCollections - 2,
},
},
{
name: "lazy system table name error",
jsonData: `{
"collections": [
{
"name": "_admins",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
},
{
name: "lazy view evaluation",
jsonData: `{
"collections": [
{
"name": "view_before",
"type": "view",
"options": {
"query": "select id, active from base_test"
}
},
{
"name": "base_test",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "view_after_new",
"type": "view",
"options": {
"query": "select id, active from base_test"
}
},
{
"name": "view_after_old",
"type": "view",
"options": {
"query": "select id from demo1"
}
}
]
}`,
expectError: false,
expectCollectionsCount: totalCollections + 4,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 3,
"OnModelAfterUpdate": 3,
"OnModelBeforeCreate": 4,
"OnModelAfterCreate": 4,
},
},
}
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
+30 -15
View File
@@ -1,11 +1,13 @@
package forms
import (
"context"
"errors"
"fmt"
"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"
@@ -16,9 +18,10 @@ import (
// RecordOAuth2LoginData defines the OA
type RecordOAuth2LoginData struct {
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
ProviderClient auth.Provider
}
// BeforeOAuth2RecordCreateFunc defines a callback function that will
@@ -43,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.
@@ -85,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),
)
}
@@ -127,6 +129,11 @@ func (form *RecordOAuth2Login) Submit(
return nil, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
provider.SetContext(ctx)
// load provider configuration
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
if err := providerConfig.SetupProvider(provider); err != nil {
@@ -135,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
}
@@ -153,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)
@@ -169,9 +183,10 @@ func (form *RecordOAuth2Login) Submit(
}
interceptorData := &RecordOAuth2LoginData{
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
ProviderClient: provider,
}
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
+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")
}
}
+105 -39
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 {
@@ -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)
@@ -235,6 +239,16 @@ func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
return nil
}
// FilesToUpload returns the parsed request files ready for upload.
func (form *RecordUpsert) FilesToUpload() map[string][]*filesystem.File {
return form.filesToUpload
}
// FilesToUpload returns the parsed request filenames ready to be deleted.
func (form *RecordUpsert) FilesToDelete() []string {
return form.filesToDelete
}
// AddFiles adds the provided file(s) to the specified file field.
//
// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
@@ -246,9 +260,9 @@ func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
//
// Example
//
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
// form.AddFiles("documents", f1, f2)
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
// form.AddFiles("documents", f1, f2)
func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile {
@@ -298,11 +312,11 @@ func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error
//
// Example
//
// // mark only only 2 files for removal
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
// // mark only only 2 files for removal
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
//
// // mark all "documents" files for removal
// form.AddFiles("documents")
// // mark all "documents" files for removal
// form.AddFiles("documents")
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
field := form.record.Collection().Schema.GetFieldByName(key)
if field == nil || field.Type != schema.FieldTypeFile {
@@ -419,7 +433,6 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
if len(submittedNames) == 0 && len(oldNames) > 0 {
form.RemoveFiles(key)
} else if len(oldNames) > 0 {
toDelete := []string{}
for _, name := range oldNames {
@@ -698,7 +711,7 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
defer tx.Rollback()
if err := txDao.SaveRecord(form.record); err != nil {
return err
return form.prepareError(err)
}
// restore record isNew state
@@ -731,52 +744,78 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
form.record.MarkAsNew()
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return fmt.Errorf("failed to process the uploaded files: %w", err)
}
dao := form.dao.Clone()
// persist the record model
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
// try to cleanup the successfully uploaded files
if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() {
log.Println(err)
// upload new files (if any)
//
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions
// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
// but BEFORE the actual record db persistence
// ---
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
if err := form.processFilesToUpload(); err != nil {
return err
}
}
return action()
}
return fmt.Errorf("failed to save the record: %w", saveErr)
if form.dao.BeforeCreateFunc != nil {
return form.dao.BeforeCreateFunc(eventDao, m, newAction)
}
return newAction()
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
if err := form.processFilesToUpload(); err != nil {
return err
}
}
return action()
}
if form.dao.BeforeUpdateFunc != nil {
return form.dao.BeforeUpdateFunc(eventDao, m, newAction)
}
return newAction()
}
// ---
// persist the record model
if err := dao.SaveRecord(form.record); err != nil {
return form.prepareError(err)
}
// delete old files (if any)
//
// 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
}, interceptors...)
}
func (form *RecordUpsert) getFilesToUploadNames() []string {
names := []string{}
for fieldKey := range form.filesToUpload {
for _, file := range form.filesToUpload[fieldKey] {
names = append(names, file.Name)
}
}
return names
}
func (form *RecordUpsert) processFilesToUpload() error {
if len(form.filesToUpload) == 0 {
return nil // no parsed file fields
}
if !form.record.HasId() {
return errors.New("the record is not persisted yet")
return errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
@@ -824,7 +863,7 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
}
if !form.record.HasId() {
return filenames, errors.New("the record doesn't have a unique ID")
return filenames, errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
@@ -857,3 +896,30 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
return filenames, nil
}
// prepareError parses the provided error and tries to return
// user-friendly validation error(s).
func (form *RecordUpsert) prepareError(err error) error {
msg := strings.ToLower(err.Error())
validationErrs := validation.Errors{}
// check for unique constraint failure
if strings.Contains(msg, "unique constraint failed") {
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
c := form.record.Collection()
for _, f := range c.Schema.Fields() {
// blank space to unify multi-columns lookup
if strings.Contains(msg+" ", strings.ToLower(c.Name+"."+f.Name)) {
validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
}
}
}
if len(validationErrs) > 0 {
return validationErrs
}
return err
}
+210 -15
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -11,14 +12,17 @@ import (
"strings"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
@@ -711,8 +715,11 @@ func TestRecordUpsertWithCustomId(t *testing.T) {
}
func TestRecordUpsertAuthRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
testName string
name string
existingId string
data map[string]any
manageAccess bool
@@ -746,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",
@@ -906,9 +913,6 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
}
for _, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
@@ -919,7 +923,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
var err error
record, err = app.Dao().FindRecordById(collection.Id, s.existingId)
if err != nil {
t.Errorf("[%s] Failed to fetch auth record with id %s", s.testName, s.existingId)
t.Errorf("[%s] Failed to fetch auth record with id %s", s.name, s.existingId)
continue
}
}
@@ -927,7 +931,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
form := forms.NewRecordUpsert(app, record)
form.SetFullManageAccess(s.manageAccess)
if err := form.LoadData(s.data); err != nil {
t.Errorf("[%s] Failed to load form data", s.testName)
t.Errorf("[%s] Failed to load form data", s.name)
continue
}
@@ -935,11 +939,119 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
hasErr := submitErr != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.testName, s.expectError, hasErr, submitErr)
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
}
if !hasErr && record.Username() == "" {
t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.testName, record)
t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.name, record)
}
}
}
func TestRecordUpsertUniqueValidator(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create a dummy collection
collection := &models.Collection{
Name: "test",
Schema: schema.NewSchema(
&schema.SchemaField{
Type: "text",
Name: "fieldA",
},
&schema.SchemaField{
Type: "text",
Name: "fieldB",
},
&schema.SchemaField{
Type: "text",
Name: "fieldC",
},
),
Indexes: types.JsonArray[string]{
// the field case shouldn't matter
"create unique index unique_single_idx on test (fielda)",
"create unique index unique_combined_idx on test (fieldb, FIELDC)",
},
}
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
dummyRecord := models.NewRecord(collection)
dummyRecord.Set("fieldA", "a")
dummyRecord.Set("fieldB", "b")
dummyRecord.Set("fieldC", "c")
if err := app.Dao().SaveRecord(dummyRecord); err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
data map[string]any
expectedErrors []string
}{
{
"duplicated unique value",
map[string]any{
"fieldA": "a",
},
[]string{"fieldA"},
},
{
"duplicated combined unique value",
map[string]any{
"fieldB": "b",
"fieldC": "c",
},
[]string{"fieldB", "fieldC"},
},
{
"non-duplicated unique value",
map[string]any{
"fieldA": "a2",
},
nil,
},
{
"non-duplicated combined unique value",
map[string]any{
"fieldB": "b",
"fieldC": "d",
},
nil,
},
}
for _, s := range scenarios {
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
if err := form.LoadData(s.data); err != nil {
t.Errorf("[%s] Failed to load form data", s.name)
continue
}
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
continue
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
continue
}
}
}
}
@@ -979,11 +1091,33 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
t.Fatal(err)
}
removed0 := "test_d61b33QdDU.txt" // replaced
removed1 := "300_WlbFWSGmW9.png"
removed2 := "logo_vcfJJG5TAh.svg"
form.AddFiles("file_one", f1) // should replace the existin file
form.AddFiles("file_many", f2, f3) // should append
form.RemoveFiles("file_many", "300_WlbFWSGmW9.png", "logo_vcfJJG5TAh.svg") // should remove
form.RemoveFiles("file_many", removed1, removed2) // should remove
filesToUpload := form.FilesToUpload()
if v, ok := filesToUpload["file_one"]; !ok || len(v) != 1 {
t.Fatalf("Expected filesToUpload[file_one] to have exactly 1 file, got %v", v)
}
if v, ok := filesToUpload["file_many"]; !ok || len(v) != 2 {
t.Fatalf("Expected filesToUpload[file_many] to have exactly 2 file, got %v", v)
}
filesToDelete := form.FilesToDelete()
if len(filesToDelete) != 3 {
t.Fatalf("Expected exactly 2 file to delete, got %v", filesToDelete)
}
for _, f := range []string{removed0, removed1, removed2} {
if !list.ExistInSlice(f, filesToDelete) {
t.Fatalf("Missing file %q from filesToDelete %v", f, filesToDelete)
}
}
if err := form.Submit(); err != nil {
t.Fatalf("Failed to submit the RecordUpsert form, got %v", err)
@@ -995,14 +1129,14 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
}
// ensure files deletion
if hasRecordFile(app, recordAfter, "test_d61b33QdDU.txt") {
if hasRecordFile(app, recordAfter, removed0) {
t.Fatalf("Expected the old file_one file to be deleted")
}
if hasRecordFile(app, recordAfter, "300_WlbFWSGmW9.png") {
t.Fatalf("Expected 300_WlbFWSGmW9.png to be deleted")
if hasRecordFile(app, recordAfter, removed1) {
t.Fatalf("Expected %s to be deleted", removed1)
}
if hasRecordFile(app, recordAfter, "logo_vcfJJG5TAh.svg") {
t.Fatalf("Expected logo_vcfJJG5TAh.svg to be deleted")
if hasRecordFile(app, recordAfter, removed2) {
t.Fatalf("Expected %s to be deleted", removed2)
}
fileOne := recordAfter.GetStringSlice("file_one")
@@ -1015,3 +1149,64 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
t.Fatalf("Expected file_many to be 5, got %v", fileMany)
}
}
func TestRecordUpsertUploadFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
testDaos := []*daos.Dao{
app.Dao(), // with hooks
daos.New(app.Dao().DB()), // without hooks
}
for i, dao := range testDaos {
// create with invalid file
{
prefix := fmt.Sprintf("%d-create", i)
new := models.NewRecord(collection)
new.Id = "123456789012341"
form := forms.NewRecordUpsert(app, new)
form.SetDao(dao)
form.LoadData(map[string]any{"title": "new_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatalf("[%s] Expected error, got nil", prefix)
}
if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
t.Fatalf("[%s] Expected the inserted record to be deleted, found \n%v", prefix, r.PublicExport())
}
}
// update with invalid file
{
prefix := fmt.Sprintf("%d-update", i)
record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordUpsert(app, record)
form.SetDao(dao)
form.LoadData(map[string]any{"title": "update_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatalf("[%s] Expected error, got nil", prefix)
}
if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
t.Fatalf("[%s] Expected the record changes to be reverted, got \n%v", prefix, r.PublicExport())
}
}
}
}
+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")
}
}
+18 -7
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,22 +61,30 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
return runInterceptors(form.Settings, func(s *settings.Settings) error {
form.Settings = s
// persists settings change
encryptionKey := os.Getenv(form.app.EncryptionEnv())
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
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()
}
// merge the application settings with the form ones
return form.app.Settings().Merge(form.Settings)
return nil
}, interceptors...)
}
+44 -42
View File
@@ -24,56 +24,58 @@ func TestEmailSendValidateAndSubmit(t *testing.T) {
}
for i, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
func() {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewTestEmailSend(app)
form.Email = s.email
form.Template = s.template
form := forms.NewTestEmailSend(app)
form.Email = s.email
form.Template = s.template
result := form.Submit()
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%d) Failed to parse errors %v", i, result)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
continue
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
continue
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%d) Failed to parse errors %v", i, result)
return
}
}
expectedEmails := 1
if len(s.expectedErrors) > 0 {
expectedEmails = 0
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
return
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
return
}
}
if app.TestMailer.TotalSend != expectedEmails {
t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
}
expectedEmails := 1
if len(s.expectedErrors) > 0 {
expectedEmails = 0
}
if len(s.expectedErrors) > 0 {
continue
}
if app.TestMailer.TotalSend != expectedEmails {
t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
}
expectedContent := "Verify"
if s.template == "password-reset" {
expectedContent = "Reset password"
} else if s.template == "email-change" {
expectedContent = "Confirm new email"
}
if len(s.expectedErrors) > 0 {
return
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
}
expectedContent := "Verify"
if s.template == "password-reset" {
expectedContent = "Reset password"
} else if s.template == "email-change" {
expectedContent = "Confirm new email"
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
}
}()
}
}
+88
View File
@@ -0,0 +1,88 @@
package forms
import (
"errors"
"fmt"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
)
const (
s3FilesystemStorage = "storage"
s3FilesystemBackups = "backups"
)
// TestS3Filesystem defines a S3 filesystem connection test.
type TestS3Filesystem struct {
app core.App
// The name of the filesystem - storage or backups
Filesystem string `form:"filesystem" json:"filesystem"`
}
// NewTestS3Filesystem creates and initializes new TestS3Filesystem form.
func NewTestS3Filesystem(app core.App) *TestS3Filesystem {
return &TestS3Filesystem{app: app}
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *TestS3Filesystem) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Filesystem,
validation.Required,
validation.In(s3FilesystemStorage, s3FilesystemBackups),
),
)
}
// Submit validates and performs a S3 filesystem connection test.
func (form *TestS3Filesystem) Submit() error {
if err := form.Validate(); err != nil {
return err
}
var s3Config settings.S3Config
if form.Filesystem == s3FilesystemBackups {
s3Config = form.app.Settings().Backups.S3
} else {
s3Config = form.app.Settings().S3
}
if !s3Config.Enabled {
return errors.New("S3 storage filesystem is not enabled")
}
fsys, err := filesystem.NewS3(
s3Config.Bucket,
s3Config.Region,
s3Config.Endpoint,
s3Config.AccessKey,
s3Config.Secret,
s3Config.ForcePathStyle,
)
if err != nil {
return fmt.Errorf("failed to initialize the S3 filesystem: %w", err)
}
defer fsys.Close()
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
testFileKey := testPrefix + "/test.txt"
// try to upload a test file
if err := fsys.Upload([]byte("test"), testFileKey); err != nil {
return fmt.Errorf("failed to upload a test file: %w", err)
}
// test prefix deletion (ensures that both bucket list and delete works)
if errs := fsys.DeletePrefix(testPrefix); len(errs) > 0 {
return fmt.Errorf("failed to delete a test file: %w", errs[0])
}
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package forms_test
import (
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestS3FilesystemValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
filesystem string
expectedErrors []string
}{
{
"empty filesystem",
"",
[]string{"filesystem"},
},
{
"invalid filesystem",
"something",
[]string{"filesystem"},
},
{
"backups filesystem",
"backups",
[]string{},
},
{
"storage filesystem",
"storage",
[]string{},
},
}
for _, s := range scenarios {
form := forms.NewTestS3Filesystem(app)
form.Filesystem = s.filesystem
result := form.Validate()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
continue
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
}
}
}
}
func TestS3FilesystemSubmitFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// check if validate was called
{
form := forms.NewTestS3Filesystem(app)
form.Filesystem = ""
result := form.Submit()
if result == nil {
t.Fatal("Expected error, got nil")
}
if _, ok := result.(validation.Errors); !ok {
t.Fatalf("Expected validation.Error, got %v", result)
}
}
// check with valid storage and disabled s3
{
form := forms.NewTestS3Filesystem(app)
form.Filesystem = "storage"
result := form.Submit()
if result == nil {
t.Fatal("Expected error, got nil")
}
if _, ok := result.(validation.Error); ok {
t.Fatalf("Didn't expect validation.Error, got %v", result)
}
}
}
+3 -1
View File
@@ -13,6 +13,7 @@ import (
// size is no more than the provided maxBytes.
//
// Example:
//
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
func UploadedFileSize(maxBytes int) validation.RuleFunc {
return func(value any) error {
@@ -36,7 +37,8 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
// mimetype is within the provided allowed mime types.
//
// Example:
// validMimeTypes := []string{"test/plain","image/jpeg"}
//
// validMimeTypes := []string{"test/plain","image/jpeg"}
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
return func(value any) error {

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