Compare commits

...
177 Commits
Author SHA1 Message Date
Gani Georgiev 3792d44c35 updated changelog 2024-01-03 14:25:51 +02:00
Gani Georgiev a021fcaa75 [#4072] added non-json value dummy object wrap normalization 2024-01-03 14:16:12 +02:00
Gani Georgiev 982f876a93 updated jsvm types 2024-01-03 11:08:30 +02:00
Gani Georgiev 1fcc2d8683 updated CHANGELOG and added t.Parallel to some of the tests 2024-01-03 10:58:25 +02:00
Gani Georgiev 4f2492290e [#4068] fixed the json field query comparisons to work correctly with plain JSON values 2024-01-03 10:43:46 +02:00
Gani Georgiev 8f625daa2f updated some of the tests to use t.Parallel 2024-01-03 04:30:20 +02:00
Gani Georgiev 0599955676 sort cascadeDelete refs for deterministic tests output 2024-01-03 04:29:30 +02:00
Gani Georgiev 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
423 changed files with 20550 additions and 10461 deletions

No files matched your search

+2 -2
View File
@@ -16,12 +16,12 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: latest node-version: 20.8.1
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
with: with:
go-version: '>=1.21.0' go-version: '>=1.21.3'
# This step usually is not needed because the /ui/dist is pregenerated locally # This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts. # but its here to ensure that each release embeds the latest admin ui artifacts.
+340 -1382
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! 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 Go code](#making-changes-in-the-go-code)
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui) - [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
## Prerequisites ## 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) - 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: 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 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. > 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. > 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 ## 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. 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: 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). - 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): To run the tests, you could execute (while in the root project directory):
```sh
go test ./...
# or using the Makefile ```sh
make test go test ./...
```
# or using the Makefile
make test
```
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)): - 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 ```sh
make lint golangci-lint run -c ./golangci.yml ./...
```
# or using the Makefile
make lint
```
## Making changes in the Admin UI ## Making changes in the Admin UI
@@ -65,14 +66,15 @@ To start the Admin UI:
1. Navigate to the `ui` project directory 1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies 2. Run `npm install` to install the node dependencies
3. Start vite's dev server 3. Start vite's dev server
```sh ```sh
npm run dev npm run dev
``` ```
You could open the browser and access the running Admin UI at `http://localhost:3000`. 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). 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. > 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. Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
+11 -10
View File
@@ -19,10 +19,10 @@
**For documentation and examples, please visit https://pocketbase.io/docs.** **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. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## API SDK clients ## API SDK clients
The easiest way to interact with the API is to use one of the official 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_) - **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_) - **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview ## Overview
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build 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 ### Installation
```sh ```sh
# go 1.18+ # go 1.21+
go get github.com/pocketbase/pocketbase 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 ### 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: 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 1. Clone/download the repo
2. Navigate to `examples/base` 2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build` 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`. 4. Start the created executable by running `./base serve`.
The supported build targets by the non-cgo driver at the moment are: The supported build targets by the non-cgo driver at the moment are:
``` ```
darwin amd64 darwin amd64
darwin arm64 darwin arm64
@@ -114,6 +113,7 @@ linux arm
linux arm64 linux arm64
linux ppc64le linux ppc64le
linux riscv64 linux riscv64
linux s390x
windows amd64 windows amd64
windows arm64 windows arm64
``` ```
@@ -122,6 +122,7 @@ windows arm64
PocketBase comes with mixed bag of unit and integration tests. PocketBase comes with mixed bag of unit and integration tests.
To run them, use the default `go test` command: To run them, use the default `go test` command:
```sh ```sh
go test ./... 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. All reports will be promptly addressed, and you'll be credited accordingly.
## Contributing ## Contributing
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md). PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
@@ -154,5 +154,6 @@ PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged. Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes). Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
_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)), > [!NOTE]
so all feature requests will be first aligned with what we need for Presentator v3._ > PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
> so all feature requests will be first aligned with what we need for Presentator v3.
+2 -4
View File
@@ -1,7 +1,6 @@
package apis package apis
import ( import (
"log"
"net/http" "net/http"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -129,9 +128,8 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error { 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 // run in background because we don't need to show the result to the client
routine.FireAndForget(func() { routine.FireAndForget(func() {
if err := next(e.Admin); err != nil && api.app.IsDebug() { if err := next(e.Admin); err != nil {
// @todo replace after logs generalization api.app.Logger().Error("Failed to send admin password reset request.", "error", err)
log.Println(err)
} }
}) })
+18
View File
@@ -17,6 +17,8 @@ import (
) )
func TestAdminAuthWithPassword(t *testing.T) { func TestAdminAuthWithPassword(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -119,6 +121,8 @@ func TestAdminAuthWithPassword(t *testing.T) {
} }
func TestAdminRequestPasswordReset(t *testing.T) { func TestAdminRequestPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -188,6 +192,8 @@ func TestAdminRequestPasswordReset(t *testing.T) {
} }
func TestAdminConfirmPasswordReset(t *testing.T) { func TestAdminConfirmPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -277,6 +283,8 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
} }
func TestAdminRefresh(t *testing.T) { func TestAdminRefresh(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -350,6 +358,8 @@ func TestAdminRefresh(t *testing.T) {
} }
func TestAdminsList(t *testing.T) { func TestAdminsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -453,6 +463,8 @@ func TestAdminsList(t *testing.T) {
} }
func TestAdminView(t *testing.T) { func TestAdminView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -508,6 +520,8 @@ func TestAdminView(t *testing.T) {
} }
func TestAdminDelete(t *testing.T) { func TestAdminDelete(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -603,6 +617,8 @@ func TestAdminDelete(t *testing.T) {
} }
func TestAdminCreate(t *testing.T) { func TestAdminCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized (while having at least 1 existing admin)", Name: "unauthorized (while having at least 1 existing admin)",
@@ -757,6 +773,8 @@ func TestAdminCreate(t *testing.T) {
} }
func TestAdminUpdate(t *testing.T) { func TestAdminUpdate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
+12
View File
@@ -10,6 +10,8 @@ import (
) )
func TestNewApiErrorWithRawData(t *testing.T) { func TestNewApiErrorWithRawData(t *testing.T) {
t.Parallel()
e := apis.NewApiError( e := apis.NewApiError(
300, 300,
"message_test", "message_test",
@@ -33,6 +35,8 @@ func TestNewApiErrorWithRawData(t *testing.T) {
} }
func TestNewApiErrorWithValidationData(t *testing.T) { func TestNewApiErrorWithValidationData(t *testing.T) {
t.Parallel()
e := apis.NewApiError( e := apis.NewApiError(
300, 300,
"message_test", "message_test",
@@ -66,6 +70,8 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
} }
func TestNewNotFoundError(t *testing.T) { func TestNewNotFoundError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
@@ -87,6 +93,8 @@ func TestNewNotFoundError(t *testing.T) {
} }
func TestNewBadRequestError(t *testing.T) { func TestNewBadRequestError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
@@ -108,6 +116,8 @@ func TestNewBadRequestError(t *testing.T) {
} }
func TestNewForbiddenError(t *testing.T) { func TestNewForbiddenError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
@@ -129,6 +139,8 @@ func TestNewForbiddenError(t *testing.T) {
} }
func TestNewUnauthorizedError(t *testing.T) { func TestNewUnauthorizedError(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
message string message string
data any data any
+5 -6
View File
@@ -2,7 +2,6 @@ package apis
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"time" "time"
@@ -69,7 +68,7 @@ func (api *backupApi) list(c echo.Context) error {
} }
func (api *backupApi) create(c echo.Context) error { func (api *backupApi) create(c echo.Context) error {
if api.app.Cache().Has(core.CacheKeyActiveBackup) { if api.app.Store().Has(core.StoreKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil) return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
} }
@@ -152,7 +151,7 @@ func (api *backupApi) download(c echo.Context) error {
} }
func (api *backupApi) restore(c echo.Context) error { func (api *backupApi) restore(c echo.Context) error {
if api.app.Cache().Has(core.CacheKeyActiveBackup) { if api.app.Store().Has(core.StoreKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil) return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
} }
@@ -181,8 +180,8 @@ func (api *backupApi) restore(c echo.Context) error {
// give some optimistic time to write the response // give some optimistic time to write the response
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() { if err := api.app.RestoreBackup(ctx, key); err != nil {
log.Println(err) api.app.Logger().Error("Failed to restore backup", "key", key, "error", err.Error())
} }
}() }()
@@ -203,7 +202,7 @@ func (api *backupApi) delete(c echo.Context) error {
key := c.PathParam("key") key := c.PathParam("key")
if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key { if key != "" && cast.ToString(api.app.Store().Get(core.StoreKeyActiveBackup)) == key {
return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil) return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
} }
+16 -4
View File
@@ -17,6 +17,8 @@ import (
) )
func TestBackupsList(t *testing.T) { func TestBackupsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -84,6 +86,8 @@ func TestBackupsList(t *testing.T) {
} }
func TestBackupsCreate(t *testing.T) { func TestBackupsCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -116,7 +120,7 @@ func TestBackupsCreate(t *testing.T) {
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.Cache().Set(core.CacheKeyActiveBackup, "") app.Store().Set(core.StoreKeyActiveBackup, "")
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app) ensureNoBackups(t, app)
@@ -198,6 +202,8 @@ func TestBackupsCreate(t *testing.T) {
} }
func TestBackupsUpload(t *testing.T) { func TestBackupsUpload(t *testing.T) {
t.Parallel()
// create dummy form data bodies // create dummy form data bodies
type body struct { type body struct {
buffer io.Reader buffer io.Reader
@@ -330,6 +336,8 @@ func TestBackupsUpload(t *testing.T) {
} }
func TestBackupsDownload(t *testing.T) { func TestBackupsDownload(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -485,6 +493,8 @@ func TestBackupsDownload(t *testing.T) {
} }
func TestBackupsDelete(t *testing.T) { func TestBackupsDelete(t *testing.T) {
t.Parallel()
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) { noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
files, err := getBackupFiles(app) files, err := getBackupFiles(app)
if err != nil { if err != nil {
@@ -562,7 +572,7 @@ func TestBackupsDelete(t *testing.T) {
} }
// mock active backup with the same name to delete // mock active backup with the same name to delete
app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip") app.Store().Set(core.StoreKeyActiveBackup, "test1.zip")
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app) noTestBackupFilesChanges(t, app)
@@ -583,7 +593,7 @@ func TestBackupsDelete(t *testing.T) {
} }
// mock active backup with different name // mock active backup with different name
app.Cache().Set(core.CacheKeyActiveBackup, "new.zip") app.Store().Set(core.StoreKeyActiveBackup, "new.zip")
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app) files, err := getBackupFiles(app)
@@ -645,6 +655,8 @@ func TestBackupsDelete(t *testing.T) {
} }
func TestBackupsRestore(t *testing.T) { func TestBackupsRestore(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -700,7 +712,7 @@ func TestBackupsRestore(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
app.Cache().Set(core.CacheKeyActiveBackup, "") app.Store().Set(core.StoreKeyActiveBackup, "")
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
+26 -29
View File
@@ -6,11 +6,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware" "github.com/labstack/echo/v5/middleware"
@@ -26,9 +27,9 @@ const trailedAdminPath = "/_/"
// system and app specific routes and middlewares. // system and app specific routes and middlewares.
func InitApi(app core.App) (*echo.Echo, error) { func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New() e := echo.New()
e.Debug = app.IsDebug() e.Debug = false
e.JSONSerializer = &rest.Serializer{ e.JSONSerializer = &rest.Serializer{
FieldsParam: "fields", FieldsParam: fieldsQueryParam,
} }
// configure a custom router // configure a custom router
@@ -49,6 +50,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
e.Pre(LoadAuthContext(app)) e.Pre(LoadAuthContext(app))
e.Use(middleware.Recover()) e.Use(middleware.Recover())
e.Use(middleware.Secure()) e.Use(middleware.Secure())
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set(ContextExecStartKey, time.Now())
return next(c)
}
})
// custom error handler // custom error handler
e.HTTPErrorHandler = func(c echo.Context, err error) { e.HTTPErrorHandler = func(c echo.Context, err error) {
@@ -56,30 +64,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
return // no error return // no error
} }
if c.Response().Committed {
if app.IsDebug() {
log.Println("HTTPErrorHandler response was already committed:", err)
}
return
}
var apiErr *ApiError var apiErr *ApiError
if errors.As(err, &apiErr) { if errors.As(err, &apiErr) {
if app.IsDebug() && apiErr.RawData() != nil { // already an api error...
log.Println(apiErr.RawData())
}
} else if v := new(echo.HTTPError); errors.As(err, &v) { } else if v := new(echo.HTTPError); errors.As(err, &v) {
if v.Internal != nil && app.IsDebug() {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message) msg := fmt.Sprintf("%v", v.Message)
apiErr = NewApiError(v.Code, msg, v) apiErr = NewApiError(v.Code, msg, v)
} else { } else {
if app.IsDebug() {
log.Println(err)
}
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
apiErr = NewNotFoundError("", err) apiErr = NewNotFoundError("", err)
} else { } else {
@@ -87,13 +79,19 @@ func InitApi(app core.App) (*echo.Echo, error) {
} }
} }
logRequest(app, c, apiErr)
if c.Response().Committed {
return // already commited
}
event := new(core.ApiErrorEvent) event := new(core.ApiErrorEvent)
event.HttpContext = c event.HttpContext = c
event.Error = apiErr event.Error = apiErr
// send error response // send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error { hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
if c.Response().Committed { if e.HttpContext.Response().Committed {
return nil return nil
} }
@@ -106,12 +104,11 @@ func InitApi(app core.App) (*echo.Echo, error) {
}) })
if hookErr == nil { if hookErr == nil {
if err := app.OnAfterApiError().Trigger(event); err != nil && app.IsDebug() { if err := app.OnAfterApiError().Trigger(event); err != nil {
log.Println(hookErr) app.Logger().Debug("OnAfterApiError failure", slog.String("error", hookErr.Error()))
} }
} else if app.IsDebug() { } else {
// truly rare case; eg. client already disconnected app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error()))
log.Println(hookErr)
} }
} }
@@ -215,7 +212,7 @@ func updateHasAdminsCache(app core.App) error {
return err return err
} }
app.Cache().Set(hasAdminsCacheKey, total > 0) app.Store().Set(hasAdminsCacheKey, total > 0)
return nil return nil
} }
@@ -240,14 +237,14 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
return next(c) return next(c)
} }
hasAdmins := cast.ToBool(app.Cache().Get(hasAdminsCacheKey)) hasAdmins := cast.ToBool(app.Store().Get(hasAdminsCacheKey))
if !hasAdmins { if !hasAdmins {
// update the cache to make sure that the admin wasn't created by another process // update the cache to make sure that the admin wasn't created by another process
if err := updateHasAdminsCache(app); err != nil { if err := updateHasAdminsCache(app); err != nil {
return err return err
} }
hasAdmins = cast.ToBool(app.Cache().Get(hasAdminsCacheKey)) hasAdmins = cast.ToBool(app.Store().Get(hasAdminsCacheKey))
} }
_, hasInstallerParam := c.Request().URL.Query()["installer"] _, hasInstallerParam := c.Request().URL.Query()["installer"]
+10
View File
@@ -15,6 +15,8 @@ import (
) )
func Test404(t *testing.T) { func Test404(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Method: http.MethodGet, Method: http.MethodGet,
@@ -53,6 +55,8 @@ func Test404(t *testing.T) {
} }
func TestCustomRoutesAndErrorsHandling(t *testing.T) { func TestCustomRoutesAndErrorsHandling(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "custom route", Name: "custom route",
@@ -142,6 +146,8 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
} }
func TestRemoveTrailingSlashMiddleware(t *testing.T) { func TestRemoveTrailingSlashMiddleware(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "non /api/* route (exact match)", Name: "non /api/* route (exact match)",
@@ -215,6 +221,8 @@ func TestRemoveTrailingSlashMiddleware(t *testing.T) {
} }
func TestEagerRequestInfoCache(t *testing.T) { func TestEagerRequestInfoCache(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "custom non-api group route", Name: "custom non-api group route",
@@ -316,6 +324,8 @@ func TestEagerRequestInfoCache(t *testing.T) {
} }
func TestErrorHandler(t *testing.T) { func TestErrorHandler(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "apis.ApiError", Name: "apis.ApiError",
+14 -2
View File
@@ -17,6 +17,8 @@ import (
) )
func TestCollectionsList(t *testing.T) { func TestCollectionsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -125,6 +127,8 @@ func TestCollectionsList(t *testing.T) {
} }
func TestCollectionView(t *testing.T) { func TestCollectionView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -193,6 +197,8 @@ func TestCollectionView(t *testing.T) {
} }
func TestCollectionDelete(t *testing.T) { func TestCollectionDelete(t *testing.T) {
t.Parallel()
ensureDeletedFiles := func(app *tests.TestApp, collectionId string) { ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId) storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
@@ -338,6 +344,8 @@ func TestCollectionDelete(t *testing.T) {
} }
func TestCollectionCreate(t *testing.T) { func TestCollectionCreate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -425,7 +433,7 @@ func TestCollectionCreate(t *testing.T) {
`"type":"auth"`, `"type":"auth"`,
`"system":false`, `"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":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":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`, `"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"onlyVerified":false,"requireEmail":false}`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1, "OnModelBeforeCreate": 1,
@@ -715,6 +723,8 @@ func TestCollectionCreate(t *testing.T) {
} }
func TestCollectionUpdate(t *testing.T) { func TestCollectionUpdate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -1082,6 +1092,8 @@ func TestCollectionUpdate(t *testing.T) {
} }
func TestCollectionsImport(t *testing.T) { func TestCollectionsImport(t *testing.T) {
t.Parallel()
totalCollections := 11 totalCollections := 11
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
@@ -1141,7 +1153,7 @@ func TestCollectionsImport(t *testing.T) {
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1, "OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 4, "OnModelBeforeDelete": 1,
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{} collections := []*models.Collection{}
+68 -9
View File
@@ -1,19 +1,26 @@
package apis package apis
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"runtime"
"strings" "strings"
"time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tokens" "github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast" "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"} var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
@@ -21,7 +28,12 @@ var defaultThumbSizes = []string{"100x100"}
// bindFileApi registers the file api endpoints and the corresponding handlers. // bindFileApi registers the file api endpoints and the corresponding handlers.
func bindFileApi(app core.App, rg *echo.Group) { 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 := rg.Group("/files", ActivityLogger(app))
subGroup.POST("/token", api.fileToken) subGroup.POST("/token", api.fileToken)
@@ -31,6 +43,18 @@ func bindFileApi(app core.App, rg *echo.Group) {
type fileApi struct { type fileApi struct {
app core.App 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 { func (api *fileApi) fileToken(c echo.Context) error {
@@ -124,11 +148,11 @@ func (api *fileApi) download(c echo.Context) error {
baseFilesPath = fileRecord.BaseFilesPath() baseFilesPath = fileRecord.BaseFilesPath()
} }
fs, err := api.app.NewFilesystem() fsys, err := api.app.NewFilesystem()
if err != nil { if err != nil {
return NewBadRequestError("Filesystem initialization failure.", err) return NewBadRequestError("Filesystem initialization failure.", err)
} }
defer fs.Close() defer fsys.Close()
originalPath := baseFilesPath + "/" + filename originalPath := baseFilesPath + "/" + filename
servedPath := originalPath servedPath := originalPath
@@ -138,7 +162,7 @@ func (api *fileApi) download(c echo.Context) error {
thumbSize := c.QueryParam("thumb") thumbSize := c.QueryParam("thumb")
if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) { if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) {
// extract the original file meta attributes and check it existence // extract the original file meta attributes and check it existence
oAttrs, oAttrsErr := fs.Attributes(originalPath) oAttrs, oAttrsErr := fsys.Attributes(originalPath)
if oAttrsErr != nil { if oAttrsErr != nil {
return NewNotFoundError("", err) return NewNotFoundError("", err)
} }
@@ -149,10 +173,19 @@ func (api *fileApi) download(c echo.Context) error {
servedName = thumbSize + "_" + filename servedName = thumbSize + "_" + filename
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
// create a new thumb if it doesn exists // create a new thumb if it doesn't exist
if exists, _ := fs.Exists(servedPath); !exists { if exists, _ := fsys.Exists(servedPath); !exists {
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil { if err := api.createThumb(c, fsys, originalPath, servedPath, thumbSize); err != nil {
servedPath = originalPath // fallback to the original api.app.Logger().Warn(
"Fallback to original - failed to create thumb "+servedName,
slog.Any("error", err),
slog.String("original", originalPath),
slog.String("thumb", servedPath),
)
// fallback to the original
servedName = filename
servedPath = originalPath
} }
} }
} }
@@ -176,7 +209,7 @@ func (api *fileApi) download(c echo.Context) error {
return nil return nil
} }
if err := fs.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil { if err := fsys.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
return NewNotFoundError("", err) return NewNotFoundError("", err)
} }
@@ -214,3 +247,29 @@ func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.M
return nil, errors.New("missing or invalid file token") 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
}
+87
View File
@@ -2,20 +2,26 @@ package apis_test
import ( import (
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sync"
"testing" "testing"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
) )
func TestFileToken(t *testing.T) { func TestFileToken(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -88,6 +94,8 @@ func TestFileToken(t *testing.T) {
} }
func TestFileDownload(t *testing.T) { func TestFileDownload(t *testing.T) {
t.Parallel()
_, currentFile, _, _ := runtime.Caller(0) _, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/" dataDirRelPath := "../tests/data/"
@@ -385,3 +393,82 @@ func TestFileDownload(t *testing.T) {
scenario.Test(t) scenario.Test(t)
} }
} }
func TestConcurrentThumbsGeneration(t *testing.T) {
t.Parallel()
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
defer app.Cleanup()
fsys, err := app.NewFilesystem()
if err != nil {
t.Fatal(err)
}
defer fsys.Close()
// create a dummy file field collection
demo1, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
fileField := demo1.Schema.GetFieldByName("file_one")
fileField.Options = &schema.FileOptions{
Protected: false,
MaxSelect: 1,
MaxSize: 999999,
// new thumbs
Thumbs: []string{"111x111", "111x222", "111x333"},
}
demo1.Schema.AddField(fileField)
if err := app.Dao().SaveCollection(demo1); err != nil {
t.Fatal(err)
}
fileKey := "wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png"
e, err := apis.InitApi(app)
if err != nil {
t.Fatal(err)
}
urls := []string{
"/api/files/" + fileKey + "?thumb=111x111",
"/api/files/" + fileKey + "?thumb=111x111", // should still result in single thumb
"/api/files/" + fileKey + "?thumb=111x222",
"/api/files/" + fileKey + "?thumb=111x333",
}
var wg sync.WaitGroup
wg.Add(len(urls))
for _, url := range urls {
url := url
go func() {
defer wg.Done()
recorder := httptest.NewRecorder()
req := httptest.NewRequest("GET", url, nil)
e.ServeHTTP(recorder, req)
}()
}
wg.Wait()
// ensure that all new requested thumbs were created
thumbKeys := []string{
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x111_" + filepath.Base(fileKey),
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x222_" + filepath.Base(fileKey),
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x333_" + filepath.Base(fileKey),
}
for _, k := range thumbKeys {
if exists, _ := fsys.Exists(k); !exists {
t.Fatalf("Missing thumb %q: %v", k, err)
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (api *healthApi) healthCheck(c echo.Context) error {
resp := new(healthCheckResponse) resp := new(healthCheckResponse)
resp.Code = http.StatusOK resp.Code = http.StatusOK
resp.Message = "API is healthy." resp.Message = "API is healthy."
resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup) resp.Data.CanBackup = !api.app.Store().Has(core.StoreKeyActiveBackup)
return c.JSON(http.StatusOK, resp) return c.JSON(http.StatusOK, resp)
} }
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestHealthAPI(t *testing.T) { func TestHealthAPI(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "health status returns 200", Name: "health status returns 200",
+18 -18
View File
@@ -15,27 +15,27 @@ func bindLogsApi(app core.App, rg *echo.Group) {
api := logsApi{app: app} api := logsApi{app: app}
subGroup := rg.Group("/logs", RequireAdminAuth()) subGroup := rg.Group("/logs", RequireAdminAuth())
subGroup.GET("/requests", api.requestsList) subGroup.GET("", api.list)
subGroup.GET("/requests/stats", api.requestsStats) subGroup.GET("/stats", api.stats)
subGroup.GET("/requests/:id", api.requestView) subGroup.GET("/:id", api.view)
} }
type logsApi struct { type logsApi struct {
app core.App app core.App
} }
var requestFilterFields = []string{ var logFilterFields = []string{
"rowid", "id", "created", "updated", "rowid", "id", "created", "updated",
"url", "method", "status", "auth", "level", "message", "data",
"remoteIp", "userIp", "referer", "userAgent", `^data\.[\w\.\:]*\w+$`,
} }
func (api *logsApi) requestsList(c echo.Context) error { func (api *logsApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...) fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
result, err := search.NewProvider(fieldResolver). result, err := search.NewProvider(fieldResolver).
Query(api.app.LogsDao().RequestQuery()). Query(api.app.LogsDao().LogQuery()).
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{}) ParseAndExec(c.QueryParams().Encode(), &[]*models.Log{})
if err != nil { if err != nil {
return NewBadRequestError("", err) return NewBadRequestError("", err)
@@ -44,8 +44,8 @@ func (api *logsApi) requestsList(c echo.Context) error {
return c.JSON(http.StatusOK, result) return c.JSON(http.StatusOK, result)
} }
func (api *logsApi) requestsStats(c echo.Context) error { func (api *logsApi) stats(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...) fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
filter := c.QueryParam(search.FilterQueryParam) 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 { 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) 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") id := c.PathParam("id")
if id == "" { if id == "" {
return NewNotFoundError("", nil) return NewNotFoundError("", nil)
} }
request, err := api.app.LogsDao().FindRequestById(id) log, err := api.app.LogsDao().FindLogById(id)
if err != nil || request == nil { if err != nil || log == nil {
return NewNotFoundError("", err) return NewNotFoundError("", err)
} }
return c.JSON(http.StatusOK, request) return c.JSON(http.StatusOK, log)
} }
+27 -21
View File
@@ -8,19 +8,21 @@ import (
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
) )
func TestRequestsList(t *testing.T) { func TestLogsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests", Url: "/api/logs",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as auth record", Name: "authorized as auth record",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests", Url: "/api/logs",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
@@ -30,12 +32,12 @@ func TestRequestsList(t *testing.T) {
{ {
Name: "authorized as admin", Name: "authorized as admin",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests", Url: "/api/logs",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { 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) t.Fatal(err)
} }
}, },
@@ -52,12 +54,12 @@ func TestRequestsList(t *testing.T) {
{ {
Name: "authorized as admin + filter", Name: "authorized as admin + filter",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests?filter=status>200", Url: "/api/logs?filter=data.status>200",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { 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) t.Fatal(err)
} }
}, },
@@ -77,19 +79,21 @@ func TestRequestsList(t *testing.T) {
} }
} }
func TestRequestView(t *testing.T) { func TestLogView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as auth record", Name: "authorized as auth record",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
@@ -99,12 +103,12 @@ func TestRequestView(t *testing.T) {
{ {
Name: "authorized as admin (nonexisting request log)", Name: "authorized as admin (nonexisting request log)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91", Url: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { 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) t.Fatal(err)
} }
}, },
@@ -114,12 +118,12 @@ func TestRequestView(t *testing.T) {
{ {
Name: "authorized as admin (existing request log)", Name: "authorized as admin (existing request log)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { 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) t.Fatal(err)
} }
}, },
@@ -135,19 +139,21 @@ func TestRequestView(t *testing.T) {
} }
} }
func TestRequestsStats(t *testing.T) { func TestLogsStats(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/stats", Url: "/api/logs/stats",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as auth record", Name: "authorized as auth record",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/stats", Url: "/api/logs/stats",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
@@ -157,12 +163,12 @@ func TestRequestsStats(t *testing.T) {
{ {
Name: "authorized as admin", Name: "authorized as admin",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/stats", Url: "/api/logs/stats",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { 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) t.Fatal(err)
} }
}, },
@@ -174,12 +180,12 @@ func TestRequestsStats(t *testing.T) {
{ {
Name: "authorized as admin + filter", Name: "authorized as admin + filter",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/stats?filter=status>200", Url: "/api/logs/stats?filter=data.status>200",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { 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) t.Fatal(err)
} }
}, },
+81 -74
View File
@@ -2,9 +2,10 @@ package apis
import ( import (
"fmt" "fmt"
"log" "log/slog"
"net" "net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -15,7 +16,6 @@ import (
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
@@ -24,6 +24,7 @@ const (
ContextAdminKey string = "admin" ContextAdminKey string = "admin"
ContextAuthRecordKey string = "authRecord" ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection" ContextCollectionKey string = "collection"
ContextExecStartKey string = "execStart"
) )
// RequireGuestOnly middleware requires a request to NOT have a valid // RequireGuestOnly middleware requires a request to NOT have a valid
@@ -285,86 +286,92 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
func ActivityLogger(app core.App) echo.MiddlewareFunc { func ActivityLogger(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
err := next(c) if err := next(c); err != nil {
logsMaxDays := app.Settings().Logs.MaxDays
// no logs retention
if logsMaxDays == 0 {
return err return err
} }
httpRequest := c.Request() logRequest(app, c, nil)
httpResponse := c.Response()
status := httpResponse.Status
meta := types.JsonMap{}
if err != nil { return nil
switch v := err.(type) {
case *echo.HTTPError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal)
case *ApiError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData())
default:
status = http.StatusBadRequest
meta["errorMessage"] = v.Error()
}
}
requestAuth := models.RequestAuthGuest
if c.Get(ContextAuthRecordKey) != nil {
requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin
}
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
model := &models.Request{
Url: httpRequest.URL.RequestURI(),
Method: strings.ToUpper(httpRequest.Method),
Status: status,
Auth: requestAuth,
UserIp: realUserIp(httpRequest, ip),
RemoteIp: ip,
Referer: httpRequest.Referer(),
UserAgent: httpRequest.UserAgent(),
Meta: meta,
}
// set timestamp fields before firing a new go routine
model.RefreshCreated()
model.RefreshUpdated()
routine.FireAndForget(func() {
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
log.Println("Log save failed:", err)
}
// Delete old request logs
// ---
now := time.Now()
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
if daysDiff > float64(logsMaxDays) {
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*logsMaxDays))
if deleteErr == nil {
app.Cache().Set("lastLogsDeletedAt", now)
} else if app.IsDebug() {
log.Println("Logs delete failed:", deleteErr)
}
}
})
return err
} }
} }
} }
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). // 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! // The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
+16
View File
@@ -10,6 +10,8 @@ import (
) )
func TestRequireGuestOnly(t *testing.T) { func TestRequireGuestOnly(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "valid record token", Name: "valid record token",
@@ -104,6 +106,8 @@ func TestRequireGuestOnly(t *testing.T) {
} }
func TestRequireRecordAuth(t *testing.T) { func TestRequireRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -242,6 +246,8 @@ func TestRequireRecordAuth(t *testing.T) {
} }
func TestRequireSameContextRecordAuth(t *testing.T) { func TestRequireSameContextRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -358,6 +364,8 @@ func TestRequireSameContextRecordAuth(t *testing.T) {
} }
func TestRequireAdminAuth(t *testing.T) { func TestRequireAdminAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -452,6 +460,8 @@ func TestRequireAdminAuth(t *testing.T) {
} }
func TestRequireAdminAuthOnlyIfAny(t *testing.T) { func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest (while having at least 1 existing admin)", Name: "guest (while having at least 1 existing admin)",
@@ -571,6 +581,8 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
} }
func TestRequireAdminOrRecordAuth(t *testing.T) { func TestRequireAdminOrRecordAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -731,6 +743,8 @@ func TestRequireAdminOrRecordAuth(t *testing.T) {
} }
func TestRequireAdminOrOwnerAuth(t *testing.T) { func TestRequireAdminOrOwnerAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -869,6 +883,8 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
} }
func TestLoadCollectionContext(t *testing.T) { func TestLoadCollectionContext(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
+225 -129
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -15,18 +15,20 @@ import (
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/spf13/cast"
) )
// bindRealtimeApi registers the realtime api endpoints. // bindRealtimeApi registers the realtime api endpoints.
func bindRealtimeApi(app core.App, rg *echo.Group) { func bindRealtimeApi(app core.App, rg *echo.Group) {
api := realtimeApi{app: app} api := realtimeApi{app: app}
subGroup := rg.Group("/realtime", ActivityLogger(app)) subGroup := rg.Group("/realtime")
subGroup.GET("", api.connect) subGroup.GET("", api.connect)
subGroup.POST("", api.setSubscriptions) subGroup.POST("", api.setSubscriptions, ActivityLogger(app))
api.bindEvents() api.bindEvents()
} }
@@ -49,16 +51,19 @@ func (api *realtimeApi) connect(c echo.Context) error {
Client: client, Client: client,
} }
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() { if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil {
log.Println(err) api.app.Logger().Debug(
"OnRealtimeDisconnectRequest error",
slog.String("clientId", client.Id()),
slog.String("error", err.Error()),
)
} }
api.app.SubscriptionsBroker().Unregister(client.Id()) 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("Cache-Control", "no-store")
c.Response().Header().Set("Connection", "keep-alive")
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640 // https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering // https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
c.Response().Header().Set("X-Accel-Buffering", "no") c.Response().Header().Set("X-Accel-Buffering", "no")
@@ -73,9 +78,7 @@ func (api *realtimeApi) connect(c echo.Context) error {
return err return err
} }
if api.app.IsDebug() { api.app.Logger().Debug("Realtime connection established.", slog.String("clientId", client.Id()))
log.Printf("Realtime connection established: %s\n", client.Id())
}
// signalize established connection (aka. fire "connect" message) // signalize established connection (aka. fire "connect" message)
connectMsgEvent := &core.RealtimeMessageEvent{ connectMsgEvent := &core.RealtimeMessageEvent{
@@ -97,9 +100,11 @@ func (api *realtimeApi) connect(c echo.Context) error {
return api.app.OnRealtimeAfterMessageSend().Trigger(e) return api.app.OnRealtimeAfterMessageSend().Trigger(e)
}) })
if connectMsgErr != nil { if connectMsgErr != nil {
if api.app.IsDebug() { api.app.Logger().Debug(
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr) "Realtime connection closed (failed to deliver PB_CONNECT)",
} slog.String("clientId", client.Id()),
slog.String("error", connectMsgErr.Error()),
)
return nil return nil
} }
@@ -115,9 +120,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
case msg, ok := <-client.Channel(): case msg, ok := <-client.Channel():
if !ok { if !ok {
// channel is closed // channel is closed
if api.app.IsDebug() { api.app.Logger().Debug(
log.Println("Realtime connection closed (closed channel):", client.Id()) "Realtime connection closed (closed channel)",
} slog.String("clientId", client.Id()),
)
return nil return nil
} }
@@ -137,9 +143,11 @@ func (api *realtimeApi) connect(c echo.Context) error {
return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent) return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
}) })
if msgErr != nil { if msgErr != nil {
if api.app.IsDebug() { api.app.Logger().Debug(
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr) "Realtime connection closed (failed to deliver message)",
} slog.String("clientId", client.Id()),
slog.String("error", msgErr.Error()),
)
return nil return nil
} }
@@ -147,9 +155,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
idleTimer.Reset(idleTimeout) idleTimer.Reset(idleTimeout)
case <-c.Request().Context().Done(): case <-c.Request().Context().Done():
// connection is closed // connection is closed
if api.app.IsDebug() { api.app.Logger().Debug(
log.Println("Realtime connection closed (cancelled request):", client.Id()) "Realtime connection closed (cancelled request)",
} slog.String("clientId", client.Id()),
)
return nil return nil
} }
} }
@@ -199,6 +208,12 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// subscribe to the new subscriptions // subscribe to the new subscriptions
e.Client.Subscribe(e.Subscriptions...) e.Client.Subscribe(e.Subscriptions...)
api.app.Logger().Debug(
"Realtime subscriptions updated.",
slog.String("clientId", e.Client.Id()),
slog.Any("subscriptions", e.Subscriptions),
)
return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error { return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
if e.HttpContext.Response().Committed { if e.HttpContext.Response().Committed {
return nil return nil
@@ -266,8 +281,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error { api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil { if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("create", record, false); err != nil && api.app.IsDebug() { if err := api.broadcastRecord("create", record, false); err != nil {
log.Println(err) 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 return nil
@@ -275,8 +295,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error { api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil { if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("update", record, false); err != nil && api.app.IsDebug() { if err := api.broadcastRecord("update", record, false); err != nil {
log.Println(err) 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 return nil
@@ -284,8 +309,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error { api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil { if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("delete", record, true); err != nil && api.app.IsDebug() { if err := api.broadcastRecord("delete", record, true); err != nil {
log.Println(err) 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 return nil
@@ -293,8 +323,13 @@ func (api *realtimeApi) bindEvents() {
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error { api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil { if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastDryCachedRecord("delete", record); err != nil && api.app.IsDebug() { if err := api.broadcastDryCachedRecord("delete", record); err != nil {
log.Println(err) 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 return nil
@@ -327,58 +362,16 @@ func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection
return collection return collection
} }
// canAccessRecord checks if the subscription client has access to the specified record model. // recordData represents the broadcasted record subscrition message data.
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
// admins can access everything
return true
}
if accessRule == nil {
// only admins can access this record
return false
}
ruleFunc := func(q *dbx.SelectQuery) error {
if *accessRule == "" {
return nil // empty public rule
}
// mock request data
requestInfo := &models.RequestInfo{
Method: "GET",
}
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
if err == nil && foundRecord != nil {
return true
}
return false
}
type recordData struct { type recordData struct {
Action string `json:"action"` Record any `json:"record"` /* map or models.Record */
Record *models.Record `json:"record"` Action string `json:"action"`
} }
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error { func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
collection := record.Collection() collection := record.Collection()
if collection == nil { if collection == nil {
return errors.New("Record collection not set.") return errors.New("[broadcastRecord] Record collection not set.")
} }
clients := api.app.SubscriptionsBroker().Clients() clients := api.app.SubscriptionsBroker().Clients()
@@ -386,67 +379,125 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
return nil // no subscribers 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{ subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule, (collection.Name + "/" + record.Id + "?"): collection.ViewRule,
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule, (collection.Id + "/" + record.Id + "?"): collection.ViewRule,
(collection.Name + "/*"): collection.ListRule, (collection.Name + "/*?"): collection.ListRule,
(collection.Id + "/*"): collection.ListRule, (collection.Id + "/*?"): collection.ListRule,
// @deprecated: the same as the wildcard topic but kept for backward compatibility // @deprecated: the same as the wildcard topic but kept for backward compatibility
collection.Name: collection.ListRule, (collection.Name + "?"): collection.ListRule,
collection.Id: collection.ListRule, (collection.Id + "?"): collection.ListRule,
} }
data := &recordData{ dryCacheKey := action + "/" + record.Id
Action: action,
Record: cleanRecord,
}
dataBytes, err := json.Marshal(data)
if err != nil {
return err
}
for _, client := range clients { for _, client := range clients {
client := client client := client
for subscription, rule := range subscriptionRuleMap { // note: not executed concurrently to avoid races and to ensure
if !client.HasSubscription(subscription) { // 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 continue
} }
if !api.canAccessRecord(client, data.Record, rule) { for sub, options := range subs {
continue // 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{ // mock request data
Name: subscription, requestInfo := &models.RequestInfo{
Data: dataBytes, Method: "GET",
} Query: options.Query,
Headers: options.Headers,
// ignore the auth record email visibility checks for
// auth owner, admin or manager
if collection.IsAuth() {
authId := extractAuthIdFromGetter(client)
if authId == data.Record.Id ||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
data.Record.IgnoreEmailVisibility(true) // ignore
if newData, err := json.Marshal(data); err == nil {
msg.Data = newData
}
data.Record.IgnoreEmailVisibility(false) // restore
} }
} requestInfo.Admin, _ = client.Get(ContextAdminKey).(*models.Admin)
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
if dryCache { if !api.canAccessRecord(cleanRecord, requestInfo, rule) {
client.Set(action+"/"+data.Record.Id, msg) continue
} else { }
routine.FireAndForget(func() {
client.Send(msg) rawExpand := cast.ToString(options.Query[expandQueryParam])
}) if rawExpand != "" {
expandErrs := api.app.Dao().ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(api.app.Dao(), requestInfo))
if len(expandErrs) > 0 {
api.app.Logger().Debug(
"[broadcastRecord] expand errors",
slog.String("id", cleanRecord.Id),
slog.String("collectionName", cleanRecord.Collection().Name),
slog.String("sub", sub),
slog.String("expand", rawExpand),
slog.Any("errors", expandErrs),
)
}
}
// ignore the auth record email visibility checks
// for auth owner, admin or manager
if collection.IsAuth() {
authId := extractAuthIdFromGetter(client)
if authId == cleanRecord.Id {
if api.canAccessRecord(cleanRecord, requestInfo, collection.AuthOptions().ManageRule) {
cleanRecord.IgnoreEmailVisibility(true)
}
}
}
data := &recordData{
Action: action,
Record: cleanRecord,
}
// check fields
rawFields := cast.ToString(options.Query[fieldsQueryParam])
if rawFields != "" {
decoded, err := rest.PickFields(cleanRecord, rawFields)
if err == nil {
data.Record = decoded
} else {
api.app.Logger().Debug(
"[broadcastRecord] pick fields error",
slog.String("id", cleanRecord.Id),
slog.String("collectionName", cleanRecord.Collection().Name),
slog.String("sub", sub),
slog.String("fields", rawFields),
slog.String("error", err.Error()),
)
}
}
dataBytes, err := json.Marshal(data)
if err != nil {
api.app.Logger().Debug(
"[broadcastRecord] data marshal error",
slog.String("id", cleanRecord.Id),
slog.String("collectionName", cleanRecord.Collection().Name),
slog.String("error", err.Error()),
)
continue
}
msg := subscriptions.Message{
Name: sub,
Data: dataBytes,
}
if dryCache {
messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
if !ok {
messages = []subscriptions.Message{msg}
} else {
messages = append(messages, msg)
}
client.Set(dryCacheKey, messages)
} else {
routine.FireAndForget(func() {
client.Send(msg)
})
}
} }
} }
} }
@@ -454,14 +505,14 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
return nil return nil
} }
// broadcastDryCachedRecord broadcasts record if it is cached in the client context. // broadcastDryCachedRecord broadcasts all cached record related messages.
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error { func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
key := action + "/" + record.Id
clients := api.app.SubscriptionsBroker().Clients() clients := api.app.SubscriptionsBroker().Clients()
for _, client := range clients { for _, client := range clients {
key := action + "/" + record.Id messages, ok := client.Get(key).([]subscriptions.Message)
msg, ok := client.Get(key).(subscriptions.Message)
if !ok { if !ok {
continue continue
} }
@@ -471,9 +522,12 @@ func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.R
client := client client := client
routine.FireAndForget(func() { routine.FireAndForget(func() {
client.Send(msg) for _, msg := range messages {
client.Send(msg)
}
}) })
} }
return nil return nil
} }
@@ -494,3 +548,45 @@ func extractAuthIdFromGetter(val getter) string {
return "" 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
}
+4
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -22,6 +23,7 @@ func TestRealtimeConnect(t *testing.T) {
{ {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/realtime", Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`id:`, `id:`,
@@ -44,6 +46,7 @@ func TestRealtimeConnect(t *testing.T) {
Name: "PB_CONNECT interrupt", Name: "PB_CONNECT interrupt",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/realtime", Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1, "OnRealtimeConnectRequest": 1,
@@ -68,6 +71,7 @@ func TestRealtimeConnect(t *testing.T) {
Name: "Skipping/ignoring messages", Name: "Skipping/ignoring messages",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/realtime", Url: "/api/realtime",
Timeout: 100 * time.Millisecond,
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1, "OnRealtimeConnectRequest": 1,
+58 -36
View File
@@ -4,8 +4,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"net/http" "net/http"
"sort"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -73,12 +74,15 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
} }
type providerInfo struct { type providerInfo struct {
Name string `json:"name"` Name string `json:"name"`
State string `json:"state"` 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"` CodeVerifier string `json:"codeVerifier"`
CodeChallenge string `json:"codeChallenge"` CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"` CodeChallengeMethod string `json:"codeChallengeMethod"`
AuthUrl string `json:"authUrl"`
} }
func (api *recordAuthApi) authMethods(c echo.Context) error { func (api *recordAuthApi) authMethods(c echo.Context) error {
@@ -90,12 +94,14 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
authOptions := collection.AuthOptions() authOptions := collection.AuthOptions()
result := struct { result := struct {
AuthProviders []providerInfo `json:"authProviders"`
UsernamePassword bool `json:"usernamePassword"` UsernamePassword bool `json:"usernamePassword"`
EmailPassword bool `json:"emailPassword"` EmailPassword bool `json:"emailPassword"`
AuthProviders []providerInfo `json:"authProviders"` OnlyVerified bool `json:"onlyVerified"`
}{ }{
UsernamePassword: authOptions.AllowUsernameAuth, UsernamePassword: authOptions.AllowUsernameAuth,
EmailPassword: authOptions.AllowEmailAuth, EmailPassword: authOptions.AllowEmailAuth,
OnlyVerified: authOptions.OnlyVerified,
AuthProviders: []providerInfo{}, AuthProviders: []providerInfo{},
} }
@@ -111,51 +117,61 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
provider, err := auth.NewProviderByName(name) provider, err := auth.NewProviderByName(name)
if err != nil { if err != nil {
if api.app.IsDebug() { api.app.Logger().Debug("Missing or invalid provier name", slog.String("name", name))
log.Println(err)
}
continue // skip provider continue // skip provider
} }
if err := config.SetupProvider(provider); err != nil { if err := config.SetupProvider(provider); err != nil {
if api.app.IsDebug() { api.app.Logger().Debug(
log.Println(err) "Failed to setup provider",
} slog.String("name", name),
slog.String("error", err.Error()),
)
continue // skip provider continue // skip provider
} }
state := security.RandomString(30) info := providerInfo{
codeVerifier := security.RandomString(43) Name: name,
codeChallenge := security.S256Challenge(codeVerifier) DisplayName: provider.DisplayName(),
codeChallengeMethod := "S256" State: security.RandomString(30),
urlOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
} }
if info.DisplayName == "" {
info.DisplayName = name
}
urlOpts := []oauth2.AuthCodeOption{}
// custom providers url options // custom providers url options
switch name { switch name {
case auth.NameApple: case auth.NameApple:
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113 // see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query")) urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
case auth.NameVK:
// vk currently doesn't support PKCE for server-side authorization
urlOpts = []oauth2.AuthCodeOption{}
} }
result.AuthProviders = append(result.AuthProviders, providerInfo{ if provider.PKCE() {
Name: name, info.CodeVerifier = security.RandomString(43)
State: state, info.CodeChallenge = security.S256Challenge(info.CodeVerifier)
CodeVerifier: codeVerifier, info.CodeChallengeMethod = "S256"
CodeChallenge: codeChallenge, urlOpts = append(urlOpts,
CodeChallengeMethod: codeChallengeMethod, oauth2.SetAuthURLParam("code_challenge", info.CodeChallenge),
AuthUrl: provider.BuildAuthUrl( oauth2.SetAuthURLParam("code_challenge_method", info.CodeChallengeMethod),
state, )
urlOpts..., }
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
}) 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) return c.JSON(http.StatusOK, result)
} }
@@ -327,8 +343,11 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error { 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 // run in background because we don't need to show the result to the client
routine.FireAndForget(func() { routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() { if err := next(e.Record); err != nil {
log.Println(err) api.app.Logger().Debug(
"Failed to send password reset email",
slog.String("error", err.Error()),
)
} }
}) })
@@ -416,8 +435,11 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error { 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 // run in background because we don't need to show the result to the client
routine.FireAndForget(func() { routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() { if err := next(e.Record); err != nil {
log.Println(err) api.app.Logger().Debug(
"Failed to send verification email",
slog.String("error", err.Error()),
)
} }
}) })
+105 -2
View File
@@ -17,6 +17,8 @@ import (
) )
func TestRecordAuthMethodsList(t *testing.T) { func TestRecordAuthMethodsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -40,6 +42,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
ExpectedContent: []string{ ExpectedContent: []string{
`"usernamePassword":true`, `"usernamePassword":true`,
`"emailPassword":true`, `"emailPassword":true`,
`"onlyVerified":false`,
`"authProviders":[{`, `"authProviders":[{`,
`"name":"gitlab"`, `"name":"gitlab"`,
`"state":`, `"state":`,
@@ -58,6 +61,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
ExpectedContent: []string{ ExpectedContent: []string{
`"usernamePassword":false`, `"usernamePassword":false`,
`"emailPassword":true`, `"emailPassword":true`,
`"onlyVerified":true`,
`"authProviders":[]`, `"authProviders":[]`,
}, },
}, },
@@ -69,6 +73,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
} }
func TestRecordAuthWithPassword(t *testing.T) { func TestRecordAuthWithPassword(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "invalid body format", Name: "invalid body format",
@@ -212,7 +218,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, Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password", Url: "/api/collections/users/auth-with-password",
Body: strings.NewReader(`{ Body: strings.NewReader(`{
@@ -225,6 +231,48 @@ func TestRecordAuthWithPassword(t *testing.T) {
`"token":"`, `"token":"`,
`"id":"4q1xlclmfloku33"`, `"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`, `"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{ ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1, "OnRecordBeforeAuthWithPasswordRequest": 1,
@@ -312,6 +360,8 @@ func TestRecordAuthWithPassword(t *testing.T) {
} }
func TestRecordAuthRefresh(t *testing.T) { func TestRecordAuthRefresh(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -377,6 +427,41 @@ func TestRecordAuthRefresh(t *testing.T) {
"OnRecordAfterAuthRefreshRequest": 1, "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", Name: "OnRecordAfterAuthRefreshRequest error response",
Method: http.MethodPost, Method: http.MethodPost,
@@ -404,6 +489,8 @@ func TestRecordAuthRefresh(t *testing.T) {
} }
func TestRecordAuthRequestPasswordReset(t *testing.T) { func TestRecordAuthRequestPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "not an auth collection", Name: "not an auth collection",
@@ -489,6 +576,8 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
} }
func TestRecordAuthConfirmPasswordReset(t *testing.T) { func TestRecordAuthConfirmPasswordReset(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -602,6 +691,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
} }
func TestRecordAuthRequestVerification(t *testing.T) { func TestRecordAuthRequestVerification(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "not an auth collection", Name: "not an auth collection",
@@ -695,6 +786,8 @@ func TestRecordAuthRequestVerification(t *testing.T) {
} }
func TestRecordAuthConfirmVerification(t *testing.T) { func TestRecordAuthConfirmVerification(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
@@ -823,6 +916,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
} }
func TestRecordAuthRequestEmailChange(t *testing.T) { func TestRecordAuthRequestEmailChange(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -898,7 +993,7 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{ ExpectedContent: []string{
`"data":`, `"data":`,
`"newEmail":{"code":"validation_record_email_exists"`, `"newEmail":{"code":"validation_record_email_invalid"`,
}, },
}, },
{ {
@@ -925,6 +1020,8 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
} }
func TestRecordAuthConfirmEmailChange(t *testing.T) { func TestRecordAuthConfirmEmailChange(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "not an auth collection", Name: "not an auth collection",
@@ -1045,6 +1142,8 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
} }
func TestRecordAuthListExternalsAuths(t *testing.T) { func TestRecordAuthListExternalsAuths(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -1145,6 +1244,8 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
} }
func TestRecordAuthUnlinkExternalsAuth(t *testing.T) { func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -1274,6 +1375,8 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
} }
func TestRecordAuthOAuth2Redirect(t *testing.T) { func TestRecordAuthOAuth2Redirect(t *testing.T) {
t.Parallel()
c1 := subscriptions.NewDefaultClient() c1 := subscriptions.NewDefaultClient()
c2 := subscriptions.NewDefaultClient() c2 := subscriptions.NewDefaultClient()
+28 -34
View File
@@ -2,9 +2,8 @@ package apis
import ( import (
"fmt" "fmt"
"log" "log/slog"
"net/http" "net/http"
"strings"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -16,8 +15,6 @@ import (
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
) )
const expandQueryParam = "expand"
// bindRecordCrudApi registers the record crud api endpoints and // bindRecordCrudApi registers the record crud api endpoints and
// the corresponding handlers. // the corresponding handlers.
func bindRecordCrudApi(app core.App, rg *echo.Group) { func bindRecordCrudApi(app core.App, rg *echo.Group) {
@@ -45,13 +42,13 @@ func (api *recordApi) list(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.") return NewNotFoundError("", "Missing collection context.")
} }
requestInfo := RequestInfo(c)
// forbid users and guests to query special filter/sort fields // forbid users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil { if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
return err return err
} }
requestInfo := RequestInfo(c)
if requestInfo.Admin == nil && collection.ListRule == nil { if requestInfo.Admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil // only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil) return NewForbiddenError("Only admins can perform this action.", nil)
@@ -76,7 +73,7 @@ func (api *recordApi) list(c echo.Context) error {
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records) result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
if err != nil { if err != nil {
return NewBadRequestError("Invalid filter parameters.", err) return NewBadRequestError("", err)
} }
event := new(core.RecordsListEvent) event := new(core.RecordsListEvent)
@@ -90,8 +87,8 @@ func (api *recordApi) list(c echo.Context) error {
return nil return nil
} }
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() { if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil {
log.Println(err) api.app.Logger().Debug("Failed to enrich list records", slog.String("error", err.Error()))
} }
return e.HttpContext.JSON(http.StatusOK, e.Result) return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -144,8 +141,13 @@ func (api *recordApi) view(c echo.Context) error {
return nil return nil
} }
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
log.Println(err) 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) return e.HttpContext.JSON(http.StatusOK, e.Record)
@@ -237,8 +239,13 @@ func (api *recordApi) create(c echo.Context) error {
return NewBadRequestError("Failed to create record.", err) return NewBadRequestError("Failed to create record.", err)
} }
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
log.Println(err) api.app.Logger().Debug(
"Failed to enrich create record",
slog.String("id", e.Record.Id),
slog.String("collectionName", e.Record.Collection().Name),
slog.String("error", err.Error()),
)
} }
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error { return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
@@ -324,8 +331,13 @@ func (api *recordApi) update(c echo.Context) error {
return NewBadRequestError("Failed to update record.", err) return NewBadRequestError("Failed to update record.", err)
} }
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
log.Println(err) api.app.Logger().Debug(
"Failed to enrich update record",
slog.String("id", e.Record.Id),
slog.String("collectionName", e.Record.Collection().Name),
slog.String("error", err.Error()),
)
} }
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error { return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
@@ -396,21 +408,3 @@ func (api *recordApi) delete(c echo.Context) error {
}) })
}) })
} }
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // admins are allowed to query everything
}
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
forbiddenFields := []string{"@collection.", "@request."}
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
+19 -2
View File
@@ -17,6 +17,8 @@ import (
) )
func TestRecordCrudList(t *testing.T) { func TestRecordCrudList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -43,9 +45,16 @@ func TestRecordCrudList(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "public collection but with admin only filter/sort (aka. @collection)", Name: "public collection but with admin only filter param (aka. @collection, @request, etc.)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'", Url: "/api/collections/demo2/records?filter=%40collection.demo2.title='test1'",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection but with admin only sort param (aka. @collection, @request, etc.)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records?sort=@request.auth.title",
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
@@ -486,6 +495,8 @@ func TestRecordCrudList(t *testing.T) {
} }
func TestRecordCrudView(t *testing.T) { func TestRecordCrudView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "missing collection", Name: "missing collection",
@@ -765,6 +776,8 @@ func TestRecordCrudView(t *testing.T) {
} }
func TestRecordCrudDelete(t *testing.T) { func TestRecordCrudDelete(t *testing.T) {
t.Parallel()
ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) { ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId) storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
@@ -1008,6 +1021,8 @@ func TestRecordCrudDelete(t *testing.T) {
} }
func TestRecordCrudCreate(t *testing.T) { func TestRecordCrudCreate(t *testing.T) {
t.Parallel()
formData, mp, err := tests.MockMultipartData(map[string]string{ formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test", "title": "title_test",
}, "files") }, "files")
@@ -1584,6 +1599,8 @@ func TestRecordCrudCreate(t *testing.T) {
} }
func TestRecordCrudUpdate(t *testing.T) { func TestRecordCrudUpdate(t *testing.T) {
t.Parallel()
formData, mp, err := tests.MockMultipartData(map[string]string{ formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test", "title": "title_test",
}, "files") }, "files")
+40 -3
View File
@@ -3,6 +3,7 @@ package apis
import ( import (
"fmt" "fmt"
"log" "log"
"log/slog"
"net/http" "net/http"
"strings" "strings"
@@ -13,12 +14,16 @@ import (
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens" "github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/rest" "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
) )
const ContextRequestInfoKey = "requestInfo" const ContextRequestInfoKey = "requestInfo"
const expandQueryParam = "expand"
const fieldsQueryParam = "fields"
// Deprecated: Use RequestInfo instead. // Deprecated: Use RequestInfo instead.
func RequestData(c echo.Context) *models.RequestInfo { 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).") log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
@@ -49,7 +54,7 @@ func RequestInfo(c echo.Context) *models.RequestInfo {
// ("X-Token" is converted to "x_token") // ("X-Token" is converted to "x_token")
for k, v := range c.Request().Header { for k, v := range c.Request().Header {
if len(v) > 0 { if len(v) > 0 {
result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0] result.Headers[inflector.Snakecase(k)] = v[0]
} }
} }
@@ -72,6 +77,10 @@ func RecordAuthResponse(
meta any, meta any,
finalizers ...func(token string) error, finalizers ...func(token string) error,
) error { ) error {
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified {
return NewForbiddenError("Please verify your email first.", nil)
}
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord) token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
if tokenErr != nil { if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr) return NewBadRequestError("Failed to create auth token.", tokenErr)
@@ -104,8 +113,8 @@ func RecordAuthResponse(
expands, expands,
expandFetch(app.Dao(), &requestInfo), expandFetch(app.Dao(), &requestInfo),
) )
if len(failed) > 0 && app.IsDebug() { if len(failed) > 0 {
log.Println("Failed to expand relations: ", failed) app.Logger().Debug("[RecordAuthResponse] Failed to expand relations", slog.Any("errors", failed))
} }
} }
@@ -305,3 +314,31 @@ func hasAuthManageAccess(
return findErr == nil return findErr == nil
} }
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
var adminOnlyRuleFields = []string{"@collection.", "@request."}
// @todo consider moving the rules check to the RecordFieldResolver.
//
// checkForAdminOnlyRuleFields loosely checks and returns an error if
// the provided RequestInfo contains rule fields that only the admin can use.
func checkForAdminOnlyRuleFields(requestInfo *models.RequestInfo) error {
if requestInfo.Admin != nil || len(requestInfo.Query) == 0 {
return nil // admin or nothing to check
}
for _, param := range ruleQueryParams {
v, _ := requestInfo.Query[param].(string)
if v == "" {
continue
}
for _, field := range adminOnlyRuleFields {
if strings.Contains(v, field) {
return NewForbiddenError("Only admins can filter by "+field, nil)
}
}
}
return nil
}
+16
View File
@@ -14,6 +14,8 @@ import (
) )
func TestRequestInfo(t *testing.T) { func TestRequestInfo(t *testing.T) {
t.Parallel()
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`)) req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
@@ -67,6 +69,8 @@ func TestRequestInfo(t *testing.T) {
} }
func TestRecordAuthResponse(t *testing.T) { func TestRecordAuthResponse(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -83,6 +87,11 @@ func TestRecordAuthResponse(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
unverfiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
if err != nil {
t.Fatal(err)
}
scenarios := []struct { scenarios := []struct {
name string name string
record *models.Record record *models.Record
@@ -97,6 +106,11 @@ func TestRecordAuthResponse(t *testing.T) {
record: nonAuthRecord, record: nonAuthRecord,
expectError: true, expectError: true,
}, },
{
name: "valid auth record but with unverified email in onlyVerified collection",
record: unverfiedAuthRecord,
expectError: true,
},
{ {
name: "valid auth record - without meta", name: "valid auth record - without meta",
record: authRecord, record: authRecord,
@@ -179,6 +193,8 @@ func TestRecordAuthResponse(t *testing.T) {
} }
func TestEnrichRecords(t *testing.T) { func TestEnrichRecords(t *testing.T) {
t.Parallel()
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil) req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+39 -5
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
@@ -52,11 +53,11 @@ type ServeConfig struct {
// //
// Example: // Example:
// //
// app.Bootstrap() // app.Bootstrap()
// apis.Serve(app, apis.ServeConfig{ // apis.Serve(app, apis.ServeConfig{
// HttpAddr: "127.0.0.1:8080", // HttpAddr: "127.0.0.1:8080",
// ShowStartBanner: false, // ShowStartBanner: false,
// }) // })
func Serve(app core.App, config ServeConfig) (*http.Server, error) { func Serve(app core.App, config ServeConfig) (*http.Server, error) {
if len(config.AllowedOrigins) == 0 { if len(config.AllowedOrigins) == 0 {
config.AllowedOrigins = []string{"*"} config.AllowedOrigins = []string{"*"}
@@ -139,6 +140,11 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
HostPolicy: autocert.HostWhitelist(hostNames...), 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{ server := &http.Server{
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
@@ -150,6 +156,9 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
// WriteTimeout: 60 * time.Second, // breaks sse! // WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router, Handler: router,
Addr: mainAddr, Addr: mainAddr,
BaseContext: func(l net.Listener) context.Context {
return baseCtx
},
} }
serveEvent := &core.ServeEvent{ serveEvent := &core.ServeEvent{
@@ -189,14 +198,39 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", 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 // try to gracefully shutdown the server on app termination
app.OnTerminate().Add(func(e *core.TerminateEvent) error { app.OnTerminate().Add(func(e *core.TerminateEvent) error {
cancelBaseCtx()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
wg.Add(1)
server.Shutdown(ctx) 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 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 // start HTTPS server
if config.HttpsAddr != "" { if config.HttpsAddr != "" {
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version // if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
+16
View File
@@ -18,6 +18,8 @@ import (
) )
func TestSettingsList(t *testing.T) { func TestSettingsList(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -80,6 +82,8 @@ func TestSettingsList(t *testing.T) {
`"instagramAuth":{`, `"instagramAuth":{`,
`"vkAuth":{`, `"vkAuth":{`,
`"yandexAuth":{`, `"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
}, },
@@ -95,6 +99,8 @@ func TestSettingsList(t *testing.T) {
} }
func TestSettingsSet(t *testing.T) { func TestSettingsSet(t *testing.T) {
t.Parallel()
validData := `{"meta":{"appName":"update_test"}}` validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
@@ -161,6 +167,8 @@ func TestSettingsSet(t *testing.T) {
`"instagramAuth":{`, `"instagramAuth":{`,
`"vkAuth":{`, `"vkAuth":{`,
`"yandexAuth":{`, `"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
`"appName":"acme_test"`, `"appName":"acme_test"`,
@@ -231,6 +239,8 @@ func TestSettingsSet(t *testing.T) {
`"instagramAuth":{`, `"instagramAuth":{`,
`"vkAuth":{`, `"vkAuth":{`,
`"yandexAuth":{`, `"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
`"appName":"update_test"`, `"appName":"update_test"`,
@@ -272,6 +282,8 @@ func TestSettingsSet(t *testing.T) {
} }
func TestSettingsTestS3(t *testing.T) { func TestSettingsTestS3(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -338,6 +350,8 @@ func TestSettingsTestS3(t *testing.T) {
} }
func TestSettingsTestEmail(t *testing.T) { func TestSettingsTestEmail(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "unauthorized", Name: "unauthorized",
@@ -502,6 +516,8 @@ func TestSettingsTestEmail(t *testing.T) {
} }
func TestGenerateAppleClientSecret(t *testing.T) { func TestGenerateAppleClientSecret(t *testing.T) {
t.Parallel()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestAdminCreateCommand(t *testing.T) { func TestAdminCreateCommand(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+12 -6
View File
@@ -5,6 +5,7 @@ package core
import ( import (
"context" "context"
"log/slog"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
@@ -48,6 +49,9 @@ type App interface {
// the users table from LogsDao will result in error. // the users table from LogsDao will result in error.
LogsDao() *daos.Dao LogsDao() *daos.Dao
// Logger returns the active app logger.
Logger() *slog.Logger
// DataDir returns the app data directory path. // DataDir returns the app data directory path.
DataDir() string DataDir() string
@@ -55,16 +59,18 @@ type App interface {
// (used for settings encryption). // (used for settings encryption).
EncryptionEnv() string EncryptionEnv() string
// IsDebug returns whether the app is in debug mode // IsDev returns whether the app is in dev mode.
// (showing more detailed error logs, executed sql statements, etc.). IsDev() bool
IsDebug() bool
// Settings returns the loaded app settings. // Settings returns the loaded app settings.
Settings() *settings.Settings Settings() *settings.Settings
// Cache returns the app internal cache store. // Deprecated: Use app.Store() instead.
Cache() *store.Store[any] Cache() *store.Store[any]
// Store returns the app runtime store.
Store() *store.Store[any]
// SubscriptionsBroker returns the app realtime subscriptions broker instance. // SubscriptionsBroker returns the app realtime subscriptions broker instance.
SubscriptionsBroker() *subscriptions.Broker SubscriptionsBroker() *subscriptions.Broker
@@ -285,14 +291,14 @@ type App interface {
// SSE client connection. // SSE client connection.
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
// OnRealtimeBeforeMessage hook is triggered right before sending // OnRealtimeBeforeMessageSend hook is triggered right before sending
// an SSE message to a client. // an SSE message to a client.
// //
// Returning [hook.StopPropagation] will prevent sending the message. // Returning [hook.StopPropagation] will prevent sending the message.
// Returning any other non-nil error will close the realtime connection. // Returning any other non-nil error will close the realtime connection.
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeMessage hook is triggered right after sending // OnRealtimeAfterMessageSend hook is triggered right after sending
// an SSE message to a client. // an SSE message to a client.
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
+174 -40
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"log" "log"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -18,10 +19,14 @@ import (
"github.com/pocketbase/pocketbase/models/settings" "github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/store" "github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
) )
const ( const (
@@ -39,8 +44,10 @@ var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base PocketBase app structure. // BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct { type BaseApp struct {
// @todo consider introducing a mutex to allow safe concurrent config changes during runtime
// configurable parameters // configurable parameters
isDebug bool isDev bool
dataDir string dataDir string
encryptionEnv string encryptionEnv string
dataMaxOpenConns int dataMaxOpenConns int
@@ -49,11 +56,12 @@ type BaseApp struct {
logsMaxIdleConns int logsMaxIdleConns int
// internals // internals
cache *store.Store[any] store *store.Store[any]
settings *settings.Settings settings *settings.Settings
dao *daos.Dao dao *daos.Dao
logsDao *daos.Dao logsDao *daos.Dao
subscriptionsBroker *subscriptions.Broker subscriptionsBroker *subscriptions.Broker
logger *slog.Logger
// app event hooks // app event hooks
onBeforeBootstrap *hook.Hook[*BootstrapEvent] onBeforeBootstrap *hook.Hook[*BootstrapEvent]
@@ -167,9 +175,9 @@ type BaseApp struct {
// BaseAppConfig defines a BaseApp configuration option // BaseAppConfig defines a BaseApp configuration option
type BaseAppConfig struct { type BaseAppConfig struct {
IsDev bool
DataDir string DataDir string
EncryptionEnv string EncryptionEnv string
IsDebug bool
DataMaxOpenConns int // default to 500 DataMaxOpenConns int // default to 500
DataMaxIdleConns int // default 20 DataMaxIdleConns int // default 20
LogsMaxOpenConns int // default to 100 LogsMaxOpenConns int // default to 100
@@ -182,14 +190,14 @@ type BaseAppConfig struct {
// To initialize the app, you need to call `app.Bootstrap()`. // To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config BaseAppConfig) *BaseApp { func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{ app := &BaseApp{
isDev: config.IsDev,
dataDir: config.DataDir, dataDir: config.DataDir,
isDebug: config.IsDebug,
encryptionEnv: config.EncryptionEnv, encryptionEnv: config.EncryptionEnv,
dataMaxOpenConns: config.DataMaxOpenConns, dataMaxOpenConns: config.DataMaxOpenConns,
dataMaxIdleConns: config.DataMaxIdleConns, dataMaxIdleConns: config.DataMaxIdleConns,
logsMaxOpenConns: config.LogsMaxOpenConns, logsMaxOpenConns: config.LogsMaxOpenConns,
logsMaxIdleConns: config.LogsMaxIdleConns, logsMaxIdleConns: config.LogsMaxIdleConns,
cache: store.New[any](nil), store: store.New[any](nil),
settings: settings.New(), settings: settings.New(),
subscriptionsBroker: subscriptions.NewBroker(), subscriptionsBroker: subscriptions.NewBroker(),
@@ -314,6 +322,17 @@ func (app *BaseApp) IsBootstrapped() bool {
return app.dao != nil && app.logsDao != nil && app.settings != nil 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 // Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.). // (aka. create data dir, open db connections, load settings, etc.).
// //
@@ -343,6 +362,10 @@ func (app *BaseApp) Bootstrap() error {
return err 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 // we don't check for an error because the db migrations may have not been executed yet
app.RefreshSettings() app.RefreshSettings()
@@ -438,10 +461,11 @@ func (app *BaseApp) EncryptionEnv() string {
return app.encryptionEnv return app.encryptionEnv
} }
// IsDebug returns whether the app is in debug mode // IsDev returns whether the app is in dev mode.
// (showing more detailed error logs, executed sql statements, etc.). //
func (app *BaseApp) IsDebug() bool { // When enabled logs, executed sql statements, etc. are printed to the stderr.
return app.isDebug func (app *BaseApp) IsDev() bool {
return app.isDev
} }
// Settings returns the loaded app settings. // Settings returns the loaded app settings.
@@ -449,9 +473,15 @@ func (app *BaseApp) Settings() *settings.Settings {
return app.settings return app.settings
} }
// Cache returns the app internal cache store. // Deprecated: Use app.Store() instead.
func (app *BaseApp) Cache() *store.Store[any] { 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. // SubscriptionsBroker returns the app realtime subscriptions broker instance.
@@ -533,17 +563,17 @@ func (app *BaseApp) Restart() error {
return err return err
} }
// optimistically reset the app bootstrap state return app.OnTerminate().Trigger(&TerminateEvent{
app.ResetBootstrapState() App: app,
IsRestart: true,
}, func(e *TerminateEvent) error {
e.App.ResetBootstrapState()
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil { // attempt to restart the bootstrap process in case execve returns an error for some reason
// restart the app bootstrap state defer e.App.Bootstrap()
app.Bootstrap()
return err return syscall.Exec(execPath, os.Args, os.Environ())
} })
return nil
} }
// RefreshSettings reinitializes and reloads the stored application settings. // RefreshSettings reinitializes and reloads the stored application settings.
@@ -555,7 +585,7 @@ func (app *BaseApp) RefreshSettings() error {
encryptionKey := os.Getenv(app.EncryptionEnv()) encryptionKey := os.Getenv(app.EncryptionEnv())
storedSettings, err := app.Dao().FindSettings(encryptionKey) storedSettings, err := app.Dao().FindSettings(encryptionKey)
if err != nil && err != sql.ErrNoRows { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err return err
} }
@@ -569,6 +599,13 @@ func (app *BaseApp) RefreshSettings() error {
return err 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 return nil
} }
@@ -988,7 +1025,7 @@ func (app *BaseApp) initLogsDB() error {
} }
concurrentDB.DB().SetMaxOpenConns(maxOpenConns) concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
concurrentDB.DB().SetMaxIdleConns(maxIdleConns) 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")) nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
if err != nil { if err != nil {
@@ -996,7 +1033,7 @@ func (app *BaseApp) initLogsDB() error {
} }
nonconcurrentDB.DB().SetMaxOpenConns(1) nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(1) nonconcurrentDB.DB().SetMaxIdleConns(1)
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute) nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB) app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
@@ -1019,7 +1056,7 @@ func (app *BaseApp) initDataDB() error {
} }
concurrentDB.DB().SetMaxOpenConns(maxOpenConns) concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
concurrentDB.DB().SetMaxIdleConns(maxIdleConns) 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")) nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
if err != nil { if err != nil {
@@ -1027,17 +1064,16 @@ func (app *BaseApp) initDataDB() error {
} }
nonconcurrentDB.DB().SetMaxOpenConns(1) nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(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) { nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql) color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
} }
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql) color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
} }
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
} }
@@ -1129,14 +1165,13 @@ func (app *BaseApp) registerDefaultHooks() {
// run in the background for "optimistic" delete to avoid // run in the background for "optimistic" delete to avoid
// blocking the delete transaction // blocking the delete transaction
//
// @todo consider creating a bg process queue so that the
// call could be "retried" in case of a failure.
routine.FireAndForget(func() { routine.FireAndForget(func() {
if err := deletePrefix(prefix); err != nil && app.IsDebug() { if err := deletePrefix(prefix); err != nil {
// non critical error - only log for debug app.Logger().Error(
// (usually could happen because of S3 api limits) "Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
log.Println(err) slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
} }
}) })
} }
@@ -1144,12 +1179,111 @@ func (app *BaseApp) registerDefaultHooks() {
return nil return nil
}) })
app.OnTerminate().Add(func(e *TerminateEvent) error { if err := app.initAutobackupHooks(); err != nil {
app.ResetBootstrapState() 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
}) })
if err := app.initAutobackupHooks(); err != nil && app.IsDebug() { return nil
log.Println(err)
}
} }
+40 -23
View File
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -22,8 +22,11 @@ import (
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
) )
// Deprecated: Replaced with StoreKeyActiveBackup.
const CacheKeyActiveBackup string = "@activeBackup" const CacheKeyActiveBackup string = "@activeBackup"
const StoreKeyActiveBackup string = "@activeBackup"
// CreateBackup creates a new backup of the current app pb_data directory. // CreateBackup creates a new backup of the current app pb_data directory.
// //
// If name is empty, it will be autogenerated. // If name is empty, it will be autogenerated.
@@ -43,7 +46,7 @@ const CacheKeyActiveBackup string = "@activeBackup"
// //
// Backups can be stored on S3 if it is configured in app.Settings().Backups. // Backups can be stored on S3 if it is configured in app.Settings().Backups.
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error { func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
if app.Cache().Has(CacheKeyActiveBackup) { if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started") return errors.New("try again later - another backup/restore operation has already been started")
} }
@@ -51,8 +54,8 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
name = app.generateBackupName("pb_backup_") name = app.generateBackupName("pb_backup_")
} }
app.Cache().Set(CacheKeyActiveBackup, name) app.Store().Set(StoreKeyActiveBackup, name)
defer app.Cache().Remove(CacheKeyActiveBackup) defer app.Store().Remove(StoreKeyActiveBackup)
// root dir entries to exclude from the backup generation // root dir entries to exclude from the backup generation
exclude := []string{LocalBackupsDirName, LocalTempDirName} exclude := []string{LocalBackupsDirName, LocalTempDirName}
@@ -135,12 +138,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return errors.New("restore is not supported on windows") return errors.New("restore is not supported on windows")
} }
if app.Cache().Has(CacheKeyActiveBackup) { if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started") return errors.New("try again later - another backup/restore operation has already been started")
} }
app.Cache().Set(CacheKeyActiveBackup, name) app.Store().Set(StoreKeyActiveBackup, name)
defer app.Cache().Remove(CacheKeyActiveBackup) defer app.Store().Remove(StoreKeyActiveBackup)
fsys, err := app.NewBackupsFilesystem() fsys, err := app.NewBackupsFilesystem()
if err != nil { if err != nil {
@@ -189,8 +192,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// remove the extracted zip file since we no longer need it // 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) // (this is in case the app restarts and the defer calls are not called)
if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() { if err := os.Remove(tempZip.Name()); err != nil {
log.Println(err) 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 // root dir entries to exclude from the backup restore
@@ -223,8 +230,8 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// restart the app // restart the app
if err := app.Restart(); err != nil { if err := app.Restart(); err != nil {
if err := revertDataDirChanges(); err != nil { if revertErr := revertDataDirChanges(); revertErr != nil {
panic(err) panic(revertErr)
} }
return fmt.Errorf("failed to restart the app process: %w", err) return fmt.Errorf("failed to restart the app process: %w", err)
@@ -251,9 +258,12 @@ func (app *BaseApp) initAutobackupHooks() error {
name := app.generateBackupName(autoPrefix) name := app.generateBackupName(autoPrefix)
if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() { if err := app.CreateBackup(context.Background(), name); err != nil {
// @todo replace after logs generalization app.Logger().Debug(
log.Println(err) "[Backup cron] Failed to create backup",
slog.String("name", name),
slog.String("error", err.Error()),
)
} }
maxKeep := app.Settings().Backups.CronMaxKeep maxKeep := app.Settings().Backups.CronMaxKeep
@@ -263,17 +273,21 @@ func (app *BaseApp) initAutobackupHooks() error {
} }
fsys, err := app.NewBackupsFilesystem() fsys, err := app.NewBackupsFilesystem()
if err != nil && app.IsDebug() { if err != nil {
// @todo replace after logs generalization app.Logger().Debug(
log.Println(err) "[Backup cron] Failed to initialize the backup filesystem",
slog.String("error", err.Error()),
)
return return
} }
defer fsys.Close() defer fsys.Close()
files, err := fsys.List(autoPrefix) files, err := fsys.List(autoPrefix)
if err != nil && app.IsDebug() { if err != nil {
// @todo replace after logs generalization app.Logger().Debug(
log.Println(err) "[Backup cron] Failed to list autogenerated backups",
slog.String("error", err.Error()),
)
return return
} }
@@ -290,9 +304,12 @@ func (app *BaseApp) initAutobackupHooks() error {
toRemove := files[maxKeep:] toRemove := files[maxKeep:]
for _, f := range toRemove { for _, f := range toRemove {
if err := fsys.Delete(f.Key); err != nil && app.IsDebug() { if err := fsys.Delete(f.Key); err != nil {
// @todo replace after logs generalization app.Logger().Debug(
log.Println(err) "[Backup cron] Failed to remove old autogenerated backup",
slog.String("key", f.Key),
slog.String("error", err.Error()),
)
} }
} }
}) })
+4 -4
View File
@@ -25,11 +25,11 @@ func TestCreateBackup(t *testing.T) {
expectedAppNamePrefix := "test_" + strings.Repeat("a", 45) expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
// test pending error // test pending error
app.Cache().Set(core.CacheKeyActiveBackup, "") app.Store().Set(core.StoreKeyActiveBackup, "")
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil { if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
t.Fatal("Expected pending error, got nil") t.Fatal("Expected pending error, got nil")
} }
app.Cache().Remove(core.CacheKeyActiveBackup) app.Store().Remove(core.StoreKeyActiveBackup)
// create with auto generated name // create with auto generated name
if err := app.CreateBackup(context.Background(), ""); err != nil { if err := app.CreateBackup(context.Background(), ""); err != nil {
@@ -98,11 +98,11 @@ func TestRestoreBackup(t *testing.T) {
} }
// test pending error // test pending error
app.Cache().Set(core.CacheKeyActiveBackup, "") app.Store().Set(core.StoreKeyActiveBackup, "")
if err := app.RestoreBackup(context.Background(), "test"); err == nil { if err := app.RestoreBackup(context.Background(), "test"); err == nil {
t.Fatal("Expected pending error, got nil") t.Fatal("Expected pending error, got nil")
} }
app.Cache().Remove(core.CacheKeyActiveBackup) app.Store().Remove(core.StoreKeyActiveBackup)
// missing backup // missing backup
if err := app.RestoreBackup(context.Background(), "missing"); err == nil { if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
+1 -1
View File
@@ -20,7 +20,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
// check if the new settings are saved in the db // check if the new settings are saved in the db
app.ResetEventCalls() app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil { 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{ testEventCalls(t, app, map[string]int{
"OnModelBeforeCreate": 1, "OnModelBeforeCreate": 1,
+324 -36
View File
@@ -1,10 +1,20 @@
package core package core
import ( import (
"fmt"
"log/slog"
"os" "os"
"testing" "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/mailer"
"github.com/pocketbase/pocketbase/tools/migrate"
) )
func TestNewBaseApp(t *testing.T) { func TestNewBaseApp(t *testing.T) {
@@ -14,7 +24,7 @@ func TestNewBaseApp(t *testing.T) {
app := NewBaseApp(BaseAppConfig{ app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
EncryptionEnv: "test_env", EncryptionEnv: "test_env",
IsDebug: true, IsDev: true,
}) })
if app.dataDir != testDataDir { if app.dataDir != testDataDir {
@@ -25,12 +35,12 @@ func TestNewBaseApp(t *testing.T) {
t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir) t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir)
} }
if !app.isDebug { if !app.isDev {
t.Fatalf("expected isDebug true, got %v", app.isDebug) t.Fatalf("expected isDev true, got %v", app.isDev)
} }
if app.cache == nil { if app.store == nil {
t.Fatal("expected cache to be set, got nil") t.Fatal("expected store to be set, got nil")
} }
if app.settings == nil { if app.settings == nil {
@@ -49,7 +59,6 @@ func TestBaseAppBootstrap(t *testing.T) {
app := NewBaseApp(BaseAppConfig{ app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
EncryptionEnv: "pb_test_env", EncryptionEnv: "pb_test_env",
IsDebug: false,
}) })
defer app.ResetBootstrapState() defer app.ResetBootstrapState()
@@ -57,7 +66,6 @@ func TestBaseAppBootstrap(t *testing.T) {
t.Fatal("Didn't expect the application to be bootstrapped.") t.Fatal("Didn't expect the application to be bootstrapped.")
} }
// bootstrap
if err := app.Bootstrap(); err != nil { if err := app.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -106,6 +114,14 @@ func TestBaseAppBootstrap(t *testing.T) {
t.Fatal("Expected app.settings to be initialized, got nil.") 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 // reset
if err := app.ResetBootstrapState(); err != nil { if err := app.ResetBootstrapState(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -127,7 +143,7 @@ func TestBaseAppGetters(t *testing.T) {
app := NewBaseApp(BaseAppConfig{ app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
EncryptionEnv: "pb_test_env", EncryptionEnv: "pb_test_env",
IsDebug: false, IsDev: true,
}) })
defer app.ResetBootstrapState() defer app.ResetBootstrapState()
@@ -159,16 +175,20 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv) t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv)
} }
if app.isDebug != app.IsDebug() { if app.isDev != app.IsDev() {
t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug) t.Fatalf("Expected app.IsDev %v, got %v", app.IsDev(), app.isDev)
} }
if app.settings != app.Settings() { if app.settings != app.Settings() {
t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings) t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
} }
if app.cache != app.Cache() { if app.store != app.Store() {
t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache) 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() { if app.subscriptionsBroker != app.SubscriptionsBroker() {
@@ -181,14 +201,11 @@ func TestBaseAppGetters(t *testing.T) {
} }
func TestBaseAppNewMailClient(t *testing.T) { func TestBaseAppNewMailClient(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" app, cleanup, err := initTestBaseApp()
defer os.RemoveAll(testDataDir) if err != nil {
t.Fatal(err)
app := NewBaseApp(BaseAppConfig{ }
DataDir: testDataDir, defer cleanup()
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
client1 := app.NewMailClient() client1 := app.NewMailClient()
if val, ok := client1.(*mailer.Sendmail); !ok { if val, ok := client1.(*mailer.Sendmail); !ok {
@@ -204,14 +221,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
} }
func TestBaseAppNewFilesystem(t *testing.T) { func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" app, cleanup, err := initTestBaseApp()
defer os.RemoveAll(testDataDir) if err != nil {
t.Fatal(err)
app := NewBaseApp(BaseAppConfig{ }
DataDir: testDataDir, defer cleanup()
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local // local
local, localErr := app.NewFilesystem() local, localErr := app.NewFilesystem()
@@ -234,14 +248,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
} }
func TestBaseAppNewBackupsFilesystem(t *testing.T) { func TestBaseAppNewBackupsFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" app, cleanup, err := initTestBaseApp()
defer os.RemoveAll(testDataDir) if err != nil {
t.Fatal(err)
app := NewBaseApp(BaseAppConfig{ }
DataDir: testDataDir, defer cleanup()
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local // local
local, localErr := app.NewBackupsFilesystem() local, localErr := app.NewBackupsFilesystem()
@@ -262,3 +273,280 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3) 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
}
+2 -1
View File
@@ -70,7 +70,8 @@ type BootstrapEvent struct {
} }
type TerminateEvent struct { type TerminateEvent struct {
App App App App
IsRestart bool
} }
type ServeEvent struct { type ServeEvent struct {
+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 return model, nil
} }
// FindAdminByToken finds the admin associated with the provided JWT token. // FindAdminByToken finds the admin associated with the provided JWT.
// //
// Returns an error if the JWT token is invalid or expired. // Returns an error if the JWT is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) { func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
// @todo consider caching the unverified claims // @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token) unverifiedClaims, err := security.ParseUnverifiedJWT(token)
+16
View File
@@ -8,6 +8,8 @@ import (
) )
func TestAdminQuery(t *testing.T) { func TestAdminQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -20,6 +22,8 @@ func TestAdminQuery(t *testing.T) {
} }
func TestFindAdminById(t *testing.T) { func TestFindAdminById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -47,6 +51,8 @@ func TestFindAdminById(t *testing.T) {
} }
func TestFindAdminByEmail(t *testing.T) { func TestFindAdminByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -76,6 +82,8 @@ func TestFindAdminByEmail(t *testing.T) {
} }
func TestFindAdminByToken(t *testing.T) { func TestFindAdminByToken(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -131,6 +139,8 @@ func TestFindAdminByToken(t *testing.T) {
} }
func TestTotalAdmins(t *testing.T) { func TestTotalAdmins(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -155,6 +165,8 @@ func TestTotalAdmins(t *testing.T) {
} }
func TestIsAdminEmailUnique(t *testing.T) { func TestIsAdminEmailUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -180,6 +192,8 @@ func TestIsAdminEmailUnique(t *testing.T) {
} }
func TestDeleteAdmin(t *testing.T) { func TestDeleteAdmin(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -225,6 +239,8 @@ func TestDeleteAdmin(t *testing.T) {
} }
func TestSaveAdmin(t *testing.T) { func TestSaveAdmin(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+1 -8
View File
@@ -6,7 +6,6 @@ package daos
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -212,13 +211,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
} }
} }
if len(errs) > 0 { if len(errs) > 0 {
// @todo after go 1.20+ upgrade consider replacing with errors.Join() return fmt.Errorf("after transaction errors: %w", errors.Join(errs...))
var errsMsg strings.Builder
for _, err := range errs {
errsMsg.WriteString(err.Error())
errsMsg.WriteString("; ")
}
return fmt.Errorf("after transaction errors: %s", errsMsg.String())
} }
return nil return nil
+9 -1
View File
@@ -2,6 +2,9 @@ package daos
import ( import (
"context" "context"
"database/sql"
"errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -23,9 +26,14 @@ func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
q.WithContext(cancelCtx) q.WithContext(cancelCtx)
} }
return baseLockRetry(func(attempt int) error { execErr := baseLockRetry(func(attempt int) error {
return op() return op()
}, maxRetries) }, maxRetries)
if execErr != nil && !errors.Is(execErr, sql.ErrNoRows) {
execErr = fmt.Errorf("%w; failed query: %s", execErr, q.SQL())
}
return execErr
} }
} }
+4
View File
@@ -6,6 +6,8 @@ import (
) )
func TestGetDefaultRetryInterval(t *testing.T) { func TestGetDefaultRetryInterval(t *testing.T) {
t.Parallel()
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 { if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i) t.Fatalf("Expected 1000ms, got %v", i)
} }
@@ -20,6 +22,8 @@ func TestGetDefaultRetryInterval(t *testing.T) {
} }
func TestBaseLockRetry(t *testing.T) { func TestBaseLockRetry(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
err error err error
failUntilAttempt int failUntilAttempt int
+2
View File
@@ -402,6 +402,8 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
// currently we don't support non-string model ids // currently we don't support non-string model ids
// (see https://github.com/pocketbase/pocketbase/issues/3110). // (see https://github.com/pocketbase/pocketbase/issues/3110).
func (dao *Dao) normalizeViewQueryId(query string) (string, error) { func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
query = strings.Trim(strings.TrimSpace(query), ";")
parsed, err := dao.parseQueryToFields(query) parsed, err := dao.parseQueryToFields(query)
if err != nil { if err != nil {
return "", err return "", err
+22
View File
@@ -16,6 +16,8 @@ import (
) )
func TestCollectionQuery(t *testing.T) { func TestCollectionQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -28,6 +30,8 @@ func TestCollectionQuery(t *testing.T) {
} }
func TestFindCollectionsByType(t *testing.T) { func TestFindCollectionsByType(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -63,6 +67,8 @@ func TestFindCollectionsByType(t *testing.T) {
} }
func TestFindCollectionByNameOrId(t *testing.T) { func TestFindCollectionByNameOrId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
} }
func TestIsCollectionNameUnique(t *testing.T) { func TestIsCollectionNameUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -116,6 +124,8 @@ func TestIsCollectionNameUnique(t *testing.T) {
} }
func TestFindCollectionReferences(t *testing.T) { func TestFindCollectionReferences(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -164,6 +174,8 @@ func TestFindCollectionReferences(t *testing.T) {
} }
func TestDeleteCollection(t *testing.T) { func TestDeleteCollection(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -251,6 +263,8 @@ func TestDeleteCollection(t *testing.T) {
} }
func TestSaveCollectionCreate(t *testing.T) { func TestSaveCollectionCreate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -297,6 +311,8 @@ func TestSaveCollectionCreate(t *testing.T) {
} }
func TestSaveCollectionUpdate(t *testing.T) { func TestSaveCollectionUpdate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -336,6 +352,8 @@ func TestSaveCollectionUpdate(t *testing.T) {
// indirect update of a field used in view should cause view(s) update // indirect update of a field used in view should cause view(s) update
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) { func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -395,6 +413,8 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
} }
func TestSaveCollectionViewWrapping(t *testing.T) { func TestSaveCollectionViewWrapping(t *testing.T) {
t.Parallel()
viewName := "test_wrapping" viewName := "test_wrapping"
scenarios := []struct { scenarios := []struct {
@@ -504,6 +524,8 @@ func TestSaveCollectionViewWrapping(t *testing.T) {
} }
func TestImportCollections(t *testing.T) { func TestImportCollections(t *testing.T) {
t.Parallel()
totalCollections := 11 totalCollections := 11
scenarios := []struct { scenarios := []struct {
+18 -21
View File
@@ -32,27 +32,6 @@ func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*mode
return auths, nil 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 // FindExternalAuthByRecordAndProvider returns the first available
// ExternalAuth model for the specified record data and provider. // ExternalAuth model for the specified record data and provider.
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) { 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 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. // SaveExternalAuth upserts the provided ExternalAuth model.
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error { func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
// extra check the model data in case the provider's API response // extra check the model data in case the provider's API response
+26 -11
View File
@@ -3,11 +3,14 @@ package daos_test
import ( import (
"testing" "testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
) )
func TestExternalAuthQuery(t *testing.T) { func TestExternalAuthQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -20,6 +23,8 @@ func TestExternalAuthQuery(t *testing.T) {
} }
func TestFindAllExternalAuthsByRecord(t *testing.T) { func TestFindAllExternalAuthsByRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -56,25 +61,25 @@ func TestFindAllExternalAuthsByRecord(t *testing.T) {
} }
} }
func TestFindExternalAuthByProvider(t *testing.T) { func TestFindFirstExternalAuthByExpr(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
scenarios := []struct { scenarios := []struct {
provider string expr dbx.Expression
providerId string
expectedId string expectedId string
}{ }{
{"", "", ""}, {dbx.HashExp{"provider": "github", "providerId": ""}, ""},
{"github", "", ""}, {dbx.HashExp{"provider": "github", "providerId": "id1"}, ""},
{"github", "id1", ""}, {dbx.HashExp{"provider": "github", "providerId": "id2"}, ""},
{"github", "id2", ""}, {dbx.HashExp{"provider": "google", "providerId": "test123"}, "clmflokuq1xl341"},
{"google", "test123", "clmflokuq1xl341"}, {dbx.HashExp{"provider": "gitlab", "providerId": "test123"}, "dlmflokuq1xl342"},
{"gitlab", "test123", "dlmflokuq1xl342"},
} }
for i, s := range scenarios { for i, s := range scenarios {
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId) auth, err := app.Dao().FindFirstExternalAuthByExpr(s.expr)
hasErr := err != nil hasErr := err != nil
expectErr := s.expectedId == "" expectErr := s.expectedId == ""
@@ -90,6 +95,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
} }
func TestFindExternalAuthByRecordAndProvider(t *testing.T) { func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -126,6 +133,8 @@ func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
} }
func TestSaveExternalAuth(t *testing.T) { func TestSaveExternalAuth(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -147,7 +156,11 @@ func TestSaveExternalAuth(t *testing.T) {
} }
// check if it was really saved // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -158,6 +171,8 @@ func TestSaveExternalAuth(t *testing.T) {
} }
func TestDeleteExternalAuth(t *testing.T) { func TestDeleteExternalAuth(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+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)
}
+42 -32
View File
@@ -11,23 +11,27 @@ import (
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
) )
func TestRequestQuery(t *testing.T) { func TestLogQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() 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 { if sql != expected {
t.Errorf("Expected sql %s, got %s", expected, sql) t.Errorf("Expected sql %s, got %s", expected, sql)
} }
} }
func TestFindRequestById(t *testing.T) { func TestFindLogById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
tests.MockRequestLogsData(app) tests.MockLogsData(app)
scenarios := []struct { scenarios := []struct {
id string id string
@@ -40,7 +44,7 @@ func TestFindRequestById(t *testing.T) {
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
admin, err := app.LogsDao().FindRequestById(scenario.id) admin, err := app.LogsDao().FindLogById(scenario.id)
hasErr := err != nil hasErr := err != nil
if hasErr != scenario.expectError { if hasErr != scenario.expectError {
@@ -53,17 +57,19 @@ func TestFindRequestById(t *testing.T) {
} }
} }
func TestRequestsStats(t *testing.T) { func TestLogsStats(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() 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"}]` 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) now := time.Now().UTC().Format(types.DefaultDateLayout)
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now}) exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
result, err := app.LogsDao().RequestsStats(exp) result, err := app.LogsDao().LogsStats(exp)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -74,20 +80,22 @@ func TestRequestsStats(t *testing.T) {
} }
} }
func TestDeleteOldRequests(t *testing.T) { func TestDeleteOldLogs(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
tests.MockRequestLogsData(app) tests.MockLogsData(app)
scenarios := []struct { scenarios := []struct {
date string date string
expectedTotal int expectedTotal int
}{ }{
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time {"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 request should have left {"2022-05-01 11:00:00.000Z", 1}, // only 1 log should have left
{"2022-05-03 11:00:00.000Z", 0}, // no more requests 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 requests should have left {"2022-05-04 11:00:00.000Z", 0}, // no more logs should have left
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
@@ -96,53 +104,55 @@ func TestDeleteOldRequests(t *testing.T) {
t.Errorf("(%d) Date error %v", i, dateErr) t.Errorf("(%d) Date error %v", i, dateErr)
} }
deleteErr := app.LogsDao().DeleteOldRequests(date) deleteErr := app.LogsDao().DeleteOldLogs(date)
if deleteErr != nil { if deleteErr != nil {
t.Errorf("(%d) Delete error %v", i, deleteErr) t.Errorf("(%d) Delete error %v", i, deleteErr)
} }
// check total remaining requests // check total remaining logs
var total int var total int
countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total) countErr := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
if countErr != nil { if countErr != nil {
t.Errorf("(%d) Count error %v", i, countErr) t.Errorf("(%d) Count error %v", i, countErr)
} }
if total != scenario.expectedTotal { 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) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
tests.MockRequestLogsData(app) tests.MockLogsData(app)
// create new request // create new log
newRequest := &models.Request{} newLog := &models.Log{}
newRequest.Method = "get" newLog.Level = -4
newRequest.Meta = types.JsonMap{} newLog.Data = types.JsonMap{}
createErr := app.LogsDao().SaveRequest(newRequest) createErr := app.LogsDao().SaveLog(newLog)
if createErr != nil { if createErr != nil {
t.Fatal(createErr) t.Fatal(createErr)
} }
// check if it was really created // check if it was really created
existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id) existingLog, fetchErr := app.LogsDao().FindLogById(newLog.Id)
if fetchErr != nil { if fetchErr != nil {
t.Fatal(fetchErr) t.Fatal(fetchErr)
} }
existingRequest.Method = "post" existingLog.Level = 4
updateErr := app.LogsDao().SaveRequest(existingRequest) updateErr := app.LogsDao().SaveLog(existingLog)
if updateErr != nil { if updateErr != nil {
t.Fatal(updateErr) t.Fatal(updateErr)
} }
// refresh instance to check if it was really updated // refresh instance to check if it was really updated
existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id) existingLog, _ = app.LogsDao().FindLogById(existingLog.Id)
if existingRequest.Method != "post" { if existingLog.Level != 4 {
t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method) t.Fatalf("Expected log level to be %d, got %d", 4, existingLog.Level)
} }
} }
+10
View File
@@ -11,6 +11,8 @@ import (
) )
func TestParamQuery(t *testing.T) { func TestParamQuery(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -23,6 +25,8 @@ func TestParamQuery(t *testing.T) {
} }
func TestFindParamByKey(t *testing.T) { func TestFindParamByKey(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -50,6 +54,8 @@ func TestFindParamByKey(t *testing.T) {
} }
func TestSaveParam(t *testing.T) { func TestSaveParam(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestSaveParam(t *testing.T) {
} }
func TestSaveParamEncrypted(t *testing.T) { func TestSaveParamEncrypted(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -126,6 +134,8 @@ func TestSaveParamEncrypted(t *testing.T) {
} }
func TestDeleteParam(t *testing.T) { func TestDeleteParam(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+20 -5
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -409,9 +410,9 @@ func (dao *Dao) IsRecordValueUnique(
return query.Row(&exists) == nil && !exists return query.Row(&exists) == nil && !exists
} }
// FindAuthRecordByToken finds the auth record associated with the provided JWT token. // FindAuthRecordByToken finds the auth record associated with the provided JWT.
// //
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record. // Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) { func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token) unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil { if err != nil {
@@ -660,9 +661,23 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error { func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4) uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
for refCollection, fields := range refs { // @todo consider changing refs to a slice
if refCollection.IsView() { //
continue // skip view collections // Sort the refs keys to ensure that the cascade events firing order is always the same.
// This is not necessary for the operation to function correctly but it helps having deterministic output during testing.
sortedRefKeys := make([]*models.Collection, 0, len(refs))
for k := range refs {
sortedRefKeys = append(sortedRefKeys, k)
}
sort.Slice(sortedRefKeys, func(i, j int) bool {
return sortedRefKeys[i].Name < sortedRefKeys[j].Name
})
for _, refCollection := range sortedRefKeys {
fields, ok := refs[refCollection]
if refCollection.IsView() || !ok {
continue // skip missing or view collections
} }
for _, field := range fields { for _, field := range fields {
+6
View File
@@ -14,6 +14,8 @@ import (
) )
func TestExpandRecords(t *testing.T) { func TestExpandRecords(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -222,6 +224,8 @@ func TestExpandRecords(t *testing.T) {
} }
func TestExpandRecord(t *testing.T) { func TestExpandRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -372,6 +376,8 @@ func TestExpandRecord(t *testing.T) {
} }
func TestIndirectExpandSingeVsArrayResult(t *testing.T) { func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+4
View File
@@ -14,6 +14,8 @@ import (
) )
func TestSyncRecordTableSchema(t *testing.T) { func TestSyncRecordTableSchema(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -132,6 +134,8 @@ func TestSyncRecordTableSchema(t *testing.T) {
} }
func TestSingleVsMultipleValuesNormalization(t *testing.T) { func TestSingleVsMultipleValuesNormalization(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+51
View File
@@ -19,6 +19,8 @@ import (
) )
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) { func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -58,6 +60,8 @@ func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
} }
func TestRecordQueryOneWithRecord(t *testing.T) { func TestRecordQueryOneWithRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -82,6 +86,8 @@ func TestRecordQueryOneWithRecord(t *testing.T) {
} }
func TestRecordQueryAllWithRecordsSlices(t *testing.T) { func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -143,6 +149,8 @@ func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
} }
func TestFindRecordById(t *testing.T) { func TestFindRecordById(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -203,6 +211,8 @@ func TestFindRecordById(t *testing.T) {
} }
func TestFindRecordsByIds(t *testing.T) { func TestFindRecordsByIds(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -294,6 +304,8 @@ func TestFindRecordsByIds(t *testing.T) {
} }
func TestFindRecordsByExpr(t *testing.T) { func TestFindRecordsByExpr(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -364,6 +376,8 @@ func TestFindRecordsByExpr(t *testing.T) {
} }
func TestFindFirstRecordByData(t *testing.T) { func TestFindFirstRecordByData(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -427,6 +441,8 @@ func TestFindFirstRecordByData(t *testing.T) {
} }
func TestFindRecordsByFilter(t *testing.T) { func TestFindRecordsByFilter(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -530,6 +546,19 @@ func TestFindRecordsByFilter(t *testing.T) {
"llvuca81nly1qls", "llvuca81nly1qls",
}, },
}, },
{
"with json filter and sort",
"demo4",
"json_object != null && json_object.a.b = 'test'",
"-json_object.a",
10,
0,
[]dbx.Params{{"active": false}},
false,
[]string{
"i9naidtvr6qsgb4",
},
},
} }
for _, s := range scenarios { for _, s := range scenarios {
@@ -566,6 +595,8 @@ func TestFindRecordsByFilter(t *testing.T) {
} }
func TestFindFirstRecordByFilter(t *testing.T) { func TestFindFirstRecordByFilter(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -647,6 +678,8 @@ func TestFindFirstRecordByFilter(t *testing.T) {
} }
func TestCanAccessRecord(t *testing.T) { func TestCanAccessRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -814,6 +847,8 @@ func TestCanAccessRecord(t *testing.T) {
} }
func TestIsRecordValueUnique(t *testing.T) { func TestIsRecordValueUnique(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -863,6 +898,8 @@ func TestIsRecordValueUnique(t *testing.T) {
} }
func TestFindAuthRecordByToken(t *testing.T) { func TestFindAuthRecordByToken(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -925,6 +962,8 @@ func TestFindAuthRecordByToken(t *testing.T) {
} }
func TestFindAuthRecordByEmail(t *testing.T) { func TestFindAuthRecordByEmail(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -956,6 +995,8 @@ func TestFindAuthRecordByEmail(t *testing.T) {
} }
func TestFindAuthRecordByUsername(t *testing.T) { func TestFindAuthRecordByUsername(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -988,6 +1029,8 @@ func TestFindAuthRecordByUsername(t *testing.T) {
} }
func TestSuggestUniqueAuthRecordUsername(t *testing.T) { func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1025,6 +1068,8 @@ func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
} }
func TestSaveRecord(t *testing.T) { func TestSaveRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1058,6 +1103,8 @@ func TestSaveRecord(t *testing.T) {
} }
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) { func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1090,6 +1137,8 @@ func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
} }
func TestDeleteRecord(t *testing.T) { func TestDeleteRecord(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1167,6 +1216,8 @@ func TestDeleteRecord(t *testing.T) {
} }
func TestDeleteRecordBatchProcessing(t *testing.T) { func TestDeleteRecordBatchProcessing(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
-70
View File
@@ -1,70 +0,0 @@
package daos
import (
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
// RequestQuery returns a new Request logs select query.
func (dao *Dao) RequestQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Request{})
}
// FindRequestById finds a single Request log by its id.
func (dao *Dao) FindRequestById(id string) (*models.Request, error) {
model := &models.Request{}
err := dao.RequestQuery().
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
type RequestsStatsItem struct {
Total int `db:"total" json:"total"`
Date types.DateTime `db:"date" json:"date"`
}
// RequestsStats returns hourly grouped requests logs statistics.
func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) {
result := []*RequestsStatsItem{}
query := dao.RequestQuery().
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
GroupBy("date")
if expr != nil {
query.AndWhere(expr)
}
err := query.All(&result)
return result, err
}
// DeleteOldRequests delete all requests that are created before createdBefore.
func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
m := models.Request{}
tableName := m.TableName()
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
_, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
return err
}
// SaveRequest upserts the provided Request model.
func (dao *Dao) SaveRequest(request *models.Request) error {
return dao.Save(request)
}
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestSaveAndFindSettings(t *testing.T) { func TestSaveAndFindSettings(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+12
View File
@@ -12,6 +12,8 @@ import (
) )
func TestHasTable(t *testing.T) { func TestHasTable(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -36,6 +38,8 @@ func TestHasTable(t *testing.T) {
} }
func TestTableColumns(t *testing.T) { func TestTableColumns(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -64,6 +68,8 @@ func TestTableColumns(t *testing.T) {
} }
func TestTableInfo(t *testing.T) { func TestTableInfo(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -92,6 +98,8 @@ func TestTableInfo(t *testing.T) {
} }
func TestDeleteTable(t *testing.T) { func TestDeleteTable(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -116,6 +124,8 @@ func TestDeleteTable(t *testing.T) {
} }
func TestVacuum(t *testing.T) { func TestVacuum(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -141,6 +151,8 @@ func TestVacuum(t *testing.T) {
} }
func TestTableIndexes(t *testing.T) { func TestTableIndexes(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+17 -7
View File
@@ -43,10 +43,10 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
return err return err
} }
trimmed := strings.Trim(selectQuery, ";") selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
// try to eagerly detect multiple inline statements // try to eagerly detect multiple inline statements
tk := tokenizer.NewFromString(trimmed) tk := tokenizer.NewFromString(selectQuery)
tk.Separators(';') tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 { if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported") return errors.New("multiple statements are not supported")
@@ -56,7 +56,7 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
// //
// note: the query is wrapped in a secondary SELECT as a rudimentary // note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution. // measure to discourage multiple inline sql statements execution.
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed) viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil { if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
return err return err
} }
@@ -232,6 +232,9 @@ func defaultViewField(name string) *schema.SchemaField {
return &schema.SchemaField{ return &schema.SchemaField{
Name: name, Name: name,
Type: schema.FieldTypeJson, Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 1, // the size doesn't matter in this case
},
} }
} }
@@ -458,7 +461,7 @@ type identifiersParser struct {
} }
func (p *identifiersParser) parse(selectQuery string) error { func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(selectQuery, ";") str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ") str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ") str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "") str = commentsReplaceRegex.ReplaceAllString(str, "")
@@ -599,13 +602,20 @@ func identifierFromParts(parts []string) (identifier, error) {
} }
result.original = trimRawIdentifier(result.original) result.original = trimRawIdentifier(result.original)
result.alias = trimRawIdentifier(result.alias)
// we trim the single quote even though it is not a valid column quote character
// because SQLite allows it if the context expects an identifier and not string literal
// (https://www.sqlite.org/lang_keywords.html)
result.alias = trimRawIdentifier(result.alias, "'")
return result, nil return result, nil
} }
func trimRawIdentifier(rawIdentifier string) string { func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
const trimChars = "`\"[];" trimChars := "`\"[];"
if len(extraTrimChars) > 0 {
trimChars += strings.Join(extraTrimChars, "")
}
parts := strings.Split(rawIdentifier, ".") parts := strings.Split(rawIdentifier, ".")
+51 -40
View File
@@ -33,6 +33,8 @@ func ensureNoTempViews(app core.App, t *testing.T) {
} }
func TestDeleteView(t *testing.T) { func TestDeleteView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -60,6 +62,8 @@ func TestDeleteView(t *testing.T) {
} }
func TestSaveView(t *testing.T) { func TestSaveView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -147,40 +151,41 @@ func TestSaveView(t *testing.T) {
} }
for _, s := range scenarios { for _, s := range scenarios {
err := app.Dao().SaveView(s.viewName, s.query) t.Run(s.scenarioName, func(t *testing.T) {
err := app.Dao().SaveView(s.viewName, s.query)
hasErr := err != nil hasErr := err != nil
if hasErr != s.expectError { if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err) t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
infoRows, err := app.Dao().TableInfo(s.viewName)
if err != nil {
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
continue
}
if len(s.expectColumns) != len(infoRows) {
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
continue
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
} }
}
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) ensureNoTempViews(app, t)
} }
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) { func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -197,6 +202,8 @@ func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
} }
func TestCreateViewSchema(t *testing.T) { func TestCreateViewSchema(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -272,24 +279,26 @@ func TestCreateViewSchema(t *testing.T) {
"datetime", "datetime",
"json", "json",
"rel_one", "rel_one",
"rel_many" "rel_many",
'single_quoted_custom_literal' as 'single_quoted_column'
from demo1 from demo1
`, `,
false, false,
map[string]string{ map[string]string{
"text": schema.FieldTypeText, "text": schema.FieldTypeText,
"bool": schema.FieldTypeBool, "bool": schema.FieldTypeBool,
"url": schema.FieldTypeUrl, "url": schema.FieldTypeUrl,
"select_one": schema.FieldTypeSelect, "select_one": schema.FieldTypeSelect,
"select_many": schema.FieldTypeSelect, "select_many": schema.FieldTypeSelect,
"file_one": schema.FieldTypeFile, "file_one": schema.FieldTypeFile,
"file_many": schema.FieldTypeFile, "file_many": schema.FieldTypeFile,
"number_alias": schema.FieldTypeNumber, "number_alias": schema.FieldTypeNumber,
"email": schema.FieldTypeEmail, "email": schema.FieldTypeEmail,
"datetime": schema.FieldTypeDate, "datetime": schema.FieldTypeDate,
"json": schema.FieldTypeJson, "json": schema.FieldTypeJson,
"rel_one": schema.FieldTypeRelation, "rel_one": schema.FieldTypeRelation,
"rel_many": schema.FieldTypeRelation, "rel_many": schema.FieldTypeRelation,
"single_quoted_column": schema.FieldTypeJson,
}, },
}, },
{ {
@@ -485,6 +494,8 @@ func TestCreateViewSchema(t *testing.T) {
} }
func TestFindRecordByViewFile(t *testing.T) { func TestFindRecordByViewFile(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+1 -1
View File
@@ -110,7 +110,7 @@ func main() {
// GitHub selfupdate // GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{}) ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { app.OnAfterBootstrap().PreAdd(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
return nil return nil
}) })
+4
View File
@@ -10,6 +10,8 @@ import (
) )
func TestAdminLoginValidateAndSubmit(t *testing.T) { func TestAdminLoginValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -51,6 +53,8 @@ func TestAdminLoginValidateAndSubmit(t *testing.T) {
} }
func TestAdminLoginInterceptors(t *testing.T) { func TestAdminLoginInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -11,6 +11,8 @@ import (
) )
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) { func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -99,6 +101,8 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
} }
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) { func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+2 -1
View File
@@ -2,6 +2,7 @@ package forms
import ( import (
"errors" "errors"
"fmt"
"time" "time"
validation "github.com/go-ozzo/ozzo-validation/v4" 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) admin, err := form.dao.FindAdminByEmail(form.Email)
if err != nil { if err != nil {
return err return fmt.Errorf("Failed to fetch admin with email %s: %w", form.Email, err)
} }
now := time.Now().UTC() now := time.Now().UTC()
@@ -10,6 +10,8 @@ import (
) )
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) { func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -74,6 +76,8 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
} }
func TestAdminPasswordResetRequestInterceptors(t *testing.T) { func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+8
View File
@@ -12,6 +12,8 @@ import (
) )
func TestNewAdminUpsert(t *testing.T) { func TestNewAdminUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -31,6 +33,8 @@ func TestNewAdminUpsert(t *testing.T) {
} }
func TestAdminUpsertValidateAndSubmit(t *testing.T) { func TestAdminUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -183,6 +187,8 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
} }
func TestAdminUpsertSubmitInterceptors(t *testing.T) { func TestAdminUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -231,6 +237,8 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
} }
func TestAdminUpsertWithCustomId(t *testing.T) { func TestAdminUpsertWithCustomId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+2 -2
View File
@@ -12,7 +12,7 @@ import (
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`) var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form. // AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
// //
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens // Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct { type AppleClientSecretCreate struct {
@@ -33,7 +33,7 @@ type AppleClientSecretCreate struct {
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----. // Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"` PrivateKey string `form:"privateKey" json:"privateKey"`
// Duration specifies how long the generated JWT token should be considered valid. // Duration specifies how long the generated JWT should be considered valid.
// The specified value must be in seconds and max 15777000 (~6months). // The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"` Duration int `form:"duration" json:"duration"`
} }
+2
View File
@@ -15,6 +15,8 @@ import (
) )
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) { func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+2
View File
@@ -10,6 +10,8 @@ import (
) )
func TestBackupCreateValidateAndSubmit(t *testing.T) { func TestBackupCreateValidateAndSubmit(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
backupName string backupName string
+2
View File
@@ -12,6 +12,8 @@ import (
) )
func TestBackupUploadValidateAndSubmit(t *testing.T) { func TestBackupUploadValidateAndSubmit(t *testing.T) {
t.Parallel()
var zb bytes.Buffer var zb bytes.Buffer
zw := zip.NewWriter(&zb) zw := zip.NewWriter(&zb)
if err := zw.Close(); err != nil { if err := zw.Close(); err != nil {
+1 -1
View File
@@ -370,7 +370,7 @@ func (form *CollectionUpsert) checkRule(value any) error {
_, err := search.FilterData(*v).BuildExpr(r) _, err := search.FilterData(*v).BuildExpr(r)
if err != nil { 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 return nil
+8
View File
@@ -16,6 +16,8 @@ import (
) )
func TestNewCollectionUpsert(t *testing.T) { func TestNewCollectionUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -88,6 +90,8 @@ func TestNewCollectionUpsert(t *testing.T) {
} }
func TestCollectionUpsertValidateAndSubmit(t *testing.T) { func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -654,6 +658,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
} }
func TestCollectionUpsertSubmitInterceptors(t *testing.T) { func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -704,6 +710,8 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
} }
func TestCollectionUpsertWithCustomId(t *testing.T) { func TestCollectionUpsertWithCustomId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+1 -5
View File
@@ -3,7 +3,6 @@ package forms
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -78,12 +77,9 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
} }
// generic/db failure // generic/db failure
if form.app.IsDebug() {
log.Println("Internal import failure:", importErr)
}
return validation.Errors{"collections": validation.NewError( return validation.Errors{"collections": validation.NewError(
"collections_import_failure", "collections_import_failure",
"Failed to import the collections configuration.", "Failed to import the collections configuration. Raw error:\n"+importErr.Error(),
)} )}
}) })
}, interceptors...) }, interceptors...)
+43 -36
View File
@@ -11,6 +11,8 @@ import (
) )
func TestCollectionsImportValidate(t *testing.T) { func TestCollectionsImportValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -38,6 +40,8 @@ func TestCollectionsImportValidate(t *testing.T) {
} }
func TestCollectionsImportSubmit(t *testing.T) { func TestCollectionsImportSubmit(t *testing.T) {
t.Parallel()
totalCollections := 11 totalCollections := 11
scenarios := []struct { scenarios := []struct {
@@ -206,7 +210,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
expectError: true, expectError: true,
expectCollectionsCount: totalCollections, expectCollectionsCount: totalCollections,
expectEvents: map[string]int{ expectEvents: map[string]int{
"OnModelBeforeDelete": 4, "OnModelBeforeDelete": 1,
}, },
}, },
{ {
@@ -418,48 +422,51 @@ func TestCollectionsImportSubmit(t *testing.T) {
} }
for _, s := range scenarios { for _, s := range scenarios {
testApp, _ := tests.NewTestApp() t.Run(s.name, func(t *testing.T) {
defer testApp.Cleanup() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewCollectionsImport(testApp) form := forms.NewCollectionsImport(testApp)
// load data // load data
loadErr := json.Unmarshal([]byte(s.jsonData), form) loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil { if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr) t.Fatalf("Failed to load form data: %v", 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)
} }
}
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)
}
}
})
} }
} }
func TestCollectionsImportSubmitInterceptors(t *testing.T) { func TestCollectionsImportSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestRealtimeSubscribeValidate(t *testing.T) { func TestRealtimeSubscribeValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
clientId string clientId string
expectError bool expectError bool
+2
View File
@@ -128,6 +128,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*mo
authRecord.SetEmail(newEmail) authRecord.SetEmail(newEmail)
authRecord.SetVerified(true) authRecord.SetVerified(true)
// @todo consider removing if not necessary anymore
authRecord.RefreshTokenKey() // invalidate old tokens authRecord.RefreshTokenKey() // invalidate old tokens
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error { interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
@@ -13,6 +13,8 @@ import (
) )
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) { func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -145,6 +147,8 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
} }
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) { func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+1 -1
View File
@@ -54,7 +54,7 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
v, _ := value.(string) v, _ := value.(string)
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) { 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 return nil
@@ -12,6 +12,8 @@ import (
) )
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) { func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -106,6 +108,8 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
} }
func TestRecordEmailChangeRequestInterceptors(t *testing.T) { func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+15 -9
View File
@@ -7,7 +7,7 @@ import (
"time" "time"
validation "github.com/go-ozzo/ozzo-validation/v4" 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/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
@@ -46,7 +46,7 @@ type RecordOAuth2Login struct {
// The authorization code returned from the initial request. // The authorization code returned from the initial request.
Code string `form:"code" json:"code"` 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"` CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
// The redirect url sent with the initial request. // The redirect url sent with the initial request.
@@ -88,8 +88,7 @@ func (form *RecordOAuth2Login) Validate() error {
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)), validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required), validation.Field(&form.Code, validation.Required),
validation.Field(&form.CodeVerifier, validation.Required), validation.Field(&form.RedirectUrl, validation.Required),
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
) )
} }
@@ -143,11 +142,14 @@ func (form *RecordOAuth2Login) Submit(
provider.SetRedirectUrl(form.RedirectUrl) provider.SetRedirectUrl(form.RedirectUrl)
var opts []oauth2.AuthCodeOption
if provider.PKCE() {
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
}
// fetch token // fetch token
token, err := provider.FetchToken( token, err := provider.FetchToken(form.Code, opts...)
form.Code,
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -161,7 +163,11 @@ func (form *RecordOAuth2Login) Submit(
var authRecord *models.Record var authRecord *models.Record
// check for existing relation with the auth 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 { switch {
case rel != nil: case rel != nil:
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId) authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
+10 -2
View File
@@ -10,6 +10,8 @@ import (
) )
func TestUserOauth2LoginValidate(t *testing.T) { func TestUserOauth2LoginValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -23,13 +25,13 @@ func TestUserOauth2LoginValidate(t *testing.T) {
"empty payload", "empty payload",
"users", "users",
"{}", "{}",
[]string{"provider", "code", "codeVerifier", "redirectUrl"}, []string{"provider", "code", "redirectUrl"},
}, },
{ {
"empty data", "empty data",
"users", "users",
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`, `{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
[]string{"provider", "code", "codeVerifier", "redirectUrl"}, []string{"provider", "code", "redirectUrl"},
}, },
{ {
"missing provider", "missing provider",
@@ -49,6 +51,12 @@ func TestUserOauth2LoginValidate(t *testing.T) {
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, `{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{}, []string{},
}, },
{
"[#3689] any redirectUrl value",
"users",
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"something"}`,
[]string{},
},
} }
for _, s := range scenarios { for _, s := range scenarios {
+4
View File
@@ -10,6 +10,8 @@ import (
) )
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) { func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -132,6 +134,8 @@ func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
} }
func TestRecordPasswordLoginInterceptors(t *testing.T) { func TestRecordPasswordLoginInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -13,6 +13,8 @@ import (
) )
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) { func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -136,6 +138,8 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
} }
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) { func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+2 -1
View File
@@ -2,6 +2,7 @@ package forms
import ( import (
"errors" "errors"
"fmt"
"time" "time"
validation "github.com/go-ozzo/ozzo-validation/v4" 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) authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
if err != nil { 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() now := time.Now().UTC()
@@ -13,6 +13,8 @@ import (
) )
func TestRecordPasswordResetRequestSubmit(t *testing.T) { func TestRecordPasswordResetRequestSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -116,6 +118,8 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
} }
func TestRecordPasswordResetRequestInterceptors(t *testing.T) { func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+27 -20
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@@ -25,7 +25,7 @@ import (
) )
// username value regex pattern // 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. // RecordUpsert is a [models.Record] upsert (create/update) form.
type RecordUpsert struct { type RecordUpsert struct {
@@ -117,7 +117,7 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
return t return t
} }
func (form *RecordUpsert) extractRequestInfo( func (form *RecordUpsert) extractRequestData(
r *http.Request, r *http.Request,
keyPrefix string, keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) { ) (map[string]any, map[string][]*filesystem.File, error) {
@@ -200,8 +200,12 @@ func (form *RecordUpsert) extractMultipartFormData(
files, err := rest.FindUploadedFiles(r, fullKey) files, err := rest.FindUploadedFiles(r, fullKey)
if err != nil || len(files) == 0 { if err != nil || len(files) == 0 {
if err != nil && err != http.ErrMissingFile && form.app.IsDebug() { if err != nil && err != http.ErrMissingFile {
log.Printf("%q uploaded file error: %v\n", fullKey, err) form.app.Logger().Debug(
"Uploaded file error",
slog.String("key", fullKey),
slog.String("error", err.Error()),
)
} }
// skip invalid or missing file(s) // skip invalid or missing file(s)
@@ -219,12 +223,12 @@ func (form *RecordUpsert) extractMultipartFormData(
// //
// File upload is supported only via multipart/form-data. // File upload is supported only via multipart/form-data.
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error { func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
requestInfo, uploadedFiles, err := form.extractRequestInfo(r, keyPrefix) requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
if err != nil { if err != nil {
return err return err
} }
if err := form.LoadData(requestInfo); err != nil { if err := form.LoadData(requestData); err != nil {
return err return err
} }
@@ -349,39 +353,39 @@ func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
} }
// LoadData loads and normalizes the provided regular record data fields into the form. // LoadData loads and normalizes the provided regular record data fields into the form.
func (form *RecordUpsert) LoadData(requestInfo map[string]any) error { func (form *RecordUpsert) LoadData(requestData map[string]any) error {
// load base system fields // load base system fields
if v, ok := requestInfo[schema.FieldNameId]; ok { if v, ok := requestData[schema.FieldNameId]; ok {
form.Id = cast.ToString(v) form.Id = cast.ToString(v)
} }
// load auth system fields // load auth system fields
if form.record.Collection().IsAuth() { if form.record.Collection().IsAuth() {
if v, ok := requestInfo[schema.FieldNameUsername]; ok { if v, ok := requestData[schema.FieldNameUsername]; ok {
form.Username = cast.ToString(v) form.Username = cast.ToString(v)
} }
if v, ok := requestInfo[schema.FieldNameEmail]; ok { if v, ok := requestData[schema.FieldNameEmail]; ok {
form.Email = cast.ToString(v) form.Email = cast.ToString(v)
} }
if v, ok := requestInfo[schema.FieldNameEmailVisibility]; ok { if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
form.EmailVisibility = cast.ToBool(v) form.EmailVisibility = cast.ToBool(v)
} }
if v, ok := requestInfo[schema.FieldNameVerified]; ok { if v, ok := requestData[schema.FieldNameVerified]; ok {
form.Verified = cast.ToBool(v) form.Verified = cast.ToBool(v)
} }
if v, ok := requestInfo["password"]; ok { if v, ok := requestData["password"]; ok {
form.Password = cast.ToString(v) form.Password = cast.ToString(v)
} }
if v, ok := requestInfo["passwordConfirm"]; ok { if v, ok := requestData["passwordConfirm"]; ok {
form.PasswordConfirm = cast.ToString(v) form.PasswordConfirm = cast.ToString(v)
} }
if v, ok := requestInfo["oldPassword"]; ok { if v, ok := requestData["oldPassword"]; ok {
form.OldPassword = cast.ToString(v) form.OldPassword = cast.ToString(v)
} }
} }
// replace modifiers (if any) // replace modifiers (if any)
requestInfo = form.record.ReplaceModifers(requestInfo) requestData = form.record.ReplaceModifers(requestData)
// create a shallow copy of form.data // create a shallow copy of form.data
var extendedData = make(map[string]any, len(form.data)) var extendedData = make(map[string]any, len(form.data))
@@ -390,7 +394,7 @@ func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
} }
// extend form.data with the request data // extend form.data with the request data
rawData, err := json.Marshal(requestInfo) rawData, err := json.Marshal(requestData)
if err != nil { if err != nil {
return err return err
} }
@@ -794,8 +798,11 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
// //
// for now fail silently to avoid reupload when `form.Submit()` // for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)... // is called manually (aka. not from an api request)...
if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() { if err := form.processFilesToDelete(); err != nil {
log.Println(err) form.app.Logger().Debug(
"Failed to delete old files",
slog.String("error", err.Error()),
)
} }
return nil return nil
+1 -1
View File
@@ -753,7 +753,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
"create with all allowed auth fields", "create with all allowed auth fields",
"", "",
map[string]any{ map[string]any{
"username": "test_new", "username": "test_new-a.b",
"email": "test_new@example.com", "email": "test_new@example.com",
"emailVisibility": true, "emailVisibility": true,
"password": "12345678", "password": "12345678",
@@ -12,6 +12,8 @@ import (
) )
func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) { func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -98,6 +100,8 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
} }
func TestRecordVerificationConfirmInterceptors(t *testing.T) { func TestRecordVerificationConfirmInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -13,6 +13,8 @@ import (
) )
func TestRecordVerificationRequestSubmit(t *testing.T) { func TestRecordVerificationRequestSubmit(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -134,6 +136,8 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
} }
func TestRecordVerificationRequestInterceptors(t *testing.T) { func TestRecordVerificationRequestInterceptors(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+16 -18
View File
@@ -4,9 +4,12 @@ import (
"os" "os"
"time" "time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/settings" "github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/types"
) )
// SettingsUpsert is a [settings.Settings] upsert (create/update) form. // SettingsUpsert is a [settings.Settings] upsert (create/update) form.
@@ -58,32 +61,27 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
return runInterceptors(form.Settings, func(s *settings.Settings) error { return runInterceptors(form.Settings, func(s *settings.Settings) error {
form.Settings = s form.Settings = s
oldSettings, err := form.app.Settings().Clone()
if err != nil {
return err
}
// eagerly merge the application settings with the form ones
if err := form.app.Settings().Merge(form.Settings); err != nil {
return err
}
// persists settings change // persists settings change
encryptionKey := os.Getenv(form.app.EncryptionEnv()) encryptionKey := os.Getenv(form.app.EncryptionEnv())
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil { if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
// try to revert app settings
form.app.Settings().Merge(oldSettings)
return err return err
} }
// explicitly trigger old logs deletion // reload app settings
form.app.LogsDao().DeleteOldRequests( if err := form.app.RefreshSettings(); err != nil {
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays), 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 { if form.Settings.Logs.MaxDays == 0 {
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
form.app.LogsDao().Vacuum() form.app.LogsDao().Vacuum()
} }
+6
View File
@@ -14,6 +14,8 @@ import (
) )
func TestNewSettingsUpsert(t *testing.T) { func TestNewSettingsUpsert(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -30,6 +32,8 @@ func TestNewSettingsUpsert(t *testing.T) {
} }
func TestSettingsUpsertValidateAndSubmit(t *testing.T) { func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -127,6 +131,8 @@ func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
} }
func TestSettingsUpsertSubmitInterceptors(t *testing.T) { func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+2
View File
@@ -10,6 +10,8 @@ import (
) )
func TestEmailSendValidateAndSubmit(t *testing.T) { func TestEmailSendValidateAndSubmit(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
template string template string
email string email string
+4
View File
@@ -9,6 +9,8 @@ import (
) )
func TestS3FilesystemValidate(t *testing.T) { func TestS3FilesystemValidate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -66,6 +68,8 @@ func TestS3FilesystemValidate(t *testing.T) {
} }
func TestS3FilesystemSubmitFailure(t *testing.T) { func TestS3FilesystemSubmitFailure(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+4
View File
@@ -12,6 +12,8 @@ import (
) )
func TestUploadedFileSize(t *testing.T) { func TestUploadedFileSize(t *testing.T) {
t.Parallel()
data, mp, err := tests.MockMultipartData(nil, "test") data, mp, err := tests.MockMultipartData(nil, "test")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -52,6 +54,8 @@ func TestUploadedFileSize(t *testing.T) {
} }
func TestUploadedFileMimeType(t *testing.T) { func TestUploadedFileMimeType(t *testing.T) {
t.Parallel()
data, mp, err := tests.MockMultipartData(nil, "test") data, mp, err := tests.MockMultipartData(nil, "test")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+2
View File
@@ -8,6 +8,8 @@ import (
) )
func TestUniqueId(t *testing.T) { func TestUniqueId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+13 -4
View File
@@ -132,11 +132,14 @@ func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField,
options, _ := field.Options.(*schema.TextOptions) options, _ := field.Options.(*schema.TextOptions)
if options.Min != nil && len(val) < *options.Min { // note: casted to []rune to count multi-byte chars as one
length := len([]rune(val))
if options.Min != nil && length < *options.Min {
return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min)) return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min))
} }
if options.Max != nil && len(val) > *options.Max { if options.Max != nil && length > *options.Max {
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max)) return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max))
} }
@@ -298,8 +301,14 @@ func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField,
} }
raw, _ := types.ParseJsonRaw(value) raw, _ := types.ParseJsonRaw(value)
rawStr := strings.TrimSpace(raw.String())
options, _ := field.Options.(*schema.JsonOptions)
if len(raw) > options.MaxSize {
return validation.NewError("validation_json_size_limit", fmt.Sprintf("The maximum allowed JSON size is %v bytes", options.MaxSize))
}
rawStr := strings.TrimSpace(raw.String())
if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) { if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
return requiredErr return requiredErr
} }
@@ -376,7 +385,7 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)). AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
Row(&total) Row(&total)
if total != len(ids) { if total != len(ids) {
return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids") return validation.NewError("validation_missing_rel_records", "Failed to find all relation records with the provided ids")
} }
// --- // ---
+71 -28
View File
@@ -45,6 +45,8 @@ func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) {
} }
func TestRecordDataValidatorValidateText(t *testing.T) { func TestRecordDataValidatorValidateText(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -63,15 +65,17 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
Name: "field2", Name: "field2",
Required: true, Required: true,
Type: schema.FieldTypeText, Type: schema.FieldTypeText,
Options: &schema.TextOptions{
Pattern: pattern,
},
}, },
&schema.SchemaField{ &schema.SchemaField{
Name: "field3", Name: "field3",
Unique: true, Unique: true,
Type: schema.FieldTypeText, Type: schema.FieldTypeText,
Options: &schema.TextOptions{ Options: &schema.TextOptions{
Min: &min, Min: &min,
Max: &max, Max: &max,
Pattern: pattern,
}, },
}, },
) )
@@ -109,6 +113,16 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
nil, nil,
[]string{"field3"}, []string{"field3"},
}, },
{
"(text) check min constraint with multi-bytes char",
map[string]any{
"field1": "test",
"field2": "test",
"field3": "𝌆", // 4 bytes should be counted as 1 char
},
nil,
[]string{"field3"},
},
{ {
"(text) check max constraint", "(text) check max constraint",
map[string]any{ map[string]any{
@@ -119,15 +133,25 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
nil, nil,
[]string{"field3"}, []string{"field3"},
}, },
{
"(text) check max constraint with multi-bytes chars",
map[string]any{
"field1": "test",
"field2": "test",
"field3": strings.Repeat("𝌆", max), // shouldn't exceed the max limit even though max*4bytes chars are used
},
nil,
[]string{},
},
{ {
"(text) check pattern constraint", "(text) check pattern constraint",
map[string]any{ map[string]any{
"field1": nil, "field1": nil,
"field2": "test", "field2": "test!",
"field3": "test!", "field3": "test",
}, },
nil, nil,
[]string{"field3"}, []string{"field2"},
}, },
{ {
"(text) valid data (only required)", "(text) valid data (only required)",
@@ -840,16 +864,25 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
&schema.SchemaField{ &schema.SchemaField{
Name: "field1", Name: "field1",
Type: schema.FieldTypeJson, Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 10,
},
}, },
&schema.SchemaField{ &schema.SchemaField{
Name: "field2", Name: "field2",
Required: true, Required: true,
Type: schema.FieldTypeJson, Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 9999,
},
}, },
&schema.SchemaField{ &schema.SchemaField{
Name: "field3", Name: "field3",
Unique: true, Unique: true,
Type: schema.FieldTypeJson, Type: schema.FieldTypeJson,
Options: &schema.JsonOptions{
MaxSize: 9999,
},
}, },
) )
if err := app.Dao().SaveCollection(collection); err != nil { if err := app.Dao().SaveCollection(collection); err != nil {
@@ -916,6 +949,15 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
nil, nil,
[]string{"field2"}, []string{"field2"},
}, },
{
"(json) check MaxSize constraint",
map[string]any{
"field1": `"123456789"`, // max 10bytes
"field2": 123,
},
nil,
[]string{"field1"},
},
{ {
"(json) check json text invalid obj, array and number normalizations", "(json) check json text invalid obj, array and number normalizations",
map[string]any{ map[string]any{
@@ -947,9 +989,9 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
{ {
"(json) valid data - all fields with normalizations", "(json) valid data - all fields with normalizations",
map[string]any{ map[string]any{
"field1": []string{"a", "b", "c"}, "field1": `"12345678"`,
"field2": 123, "field2": 123,
"field3": `"test"`, "field3": []string{"a", "b", "c"},
}, },
nil, nil,
[]string{}, []string{},
@@ -1251,29 +1293,30 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) { func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) {
for i, s := range scenarios { for i, s := range scenarios {
validator := validators.NewRecordDataValidator(dao, record, s.files) prefix := s.name
result := validator.Validate(s.data) if prefix == "" {
prefix = fmt.Sprintf("%d", i)
prefix := fmt.Sprintf("%d", i)
if s.name != "" {
prefix = s.name
} }
// parse errors t.Run(prefix, func(t *testing.T) {
errs, ok := result.(validation.Errors) validator := validators.NewRecordDataValidator(dao, record, s.files)
if !ok && result != nil { result := validator.Validate(s.data)
t.Errorf("[%s] Failed to parse errors %v", prefix, result)
continue
}
// check errors // parse errors
if len(errs) > len(s.expectedErrors) { errs, ok := result.(validation.Errors)
t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs) if !ok && result != nil {
} t.Fatalf("Failed to parse errors %v", result)
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs)
} }
}
// 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)
}
}
})
} }
} }
+2
View File
@@ -7,6 +7,8 @@ import (
) )
func TestCompare(t *testing.T) { func TestCompare(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
valA string valA string
valB string valB string
+55 -54
View File
@@ -1,94 +1,95 @@
module github.com/pocketbase/pocketbase module github.com/pocketbase/pocketbase
go 1.18 go 1.21
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go v1.44.333 github.com/aws/aws-sdk-go v1.48.16
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2 github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1 github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/dop251/goja_nodejs v0.0.0-20230821135201-94e508132562 github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c
github.com/fatih/color v1.15.0 github.com/fatih/color v1.16.0
github.com/fsnotify/fsnotify v1.6.0 github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.2 github.com/gabriel-vasile/mimetype v1.4.3
github.com/ganigeorgiev/fexpr v0.3.0 github.com/ganigeorgiev/fexpr v0.4.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/goccy/go-json v0.10.2
github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-jwt/jwt/v4 v4.5.0
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/mattn/go-sqlite3 v1.14.17 github.com/mattn/go-sqlite3 v1.14.18
github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1 github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4
github.com/spf13/cast v1.5.1 github.com/spf13/cast v1.6.0
github.com/spf13/cobra v1.7.0 github.com/spf13/cobra v1.8.0
gocloud.dev v0.34.0 gocloud.dev v0.35.0
golang.org/x/crypto v0.12.0 golang.org/x/crypto v0.16.0
golang.org/x/net v0.14.0 golang.org/x/net v0.19.0
golang.org/x/oauth2 v0.11.0 golang.org/x/oauth2 v0.15.0
golang.org/x/sync v0.3.0 golang.org/x/sync v0.5.0
modernc.org/sqlite v1.25.0 modernc.org/sqlite v1.27.0
) )
require ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.21.0 // indirect github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.37 // indirect github.com/aws/aws-sdk-go-v2/config v1.26.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.35 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.81 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.42 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.5 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.13.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.21.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
github.com/aws/smithy-go v1.14.2 // indirect github.com/aws/smithy-go v1.19.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 // indirect github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
github.com/google/uuid v1.3.1 // indirect github.com/google/uuid v1.4.0 // indirect
github.com/google/wire v0.5.0 // indirect github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.11.0 // indirect golang.org/x/image v0.14.0 // indirect
golang.org/x/mod v0.12.0 // indirect golang.org/x/mod v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.11.0 // indirect golang.org/x/term v0.15.0 // indirect
golang.org/x/text v0.12.0 // indirect golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.12.0 // indirect golang.org/x/tools v0.16.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/api v0.138.0 // indirect google.golang.org/api v0.153.0 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect
google.golang.org/grpc v1.57.0 // indirect google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.24.1 // indirect modernc.org/libc v1.37.0 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.1 // indirect modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff. Show more