Compare commits

...
511 Commits
Author SHA1 Message Date
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 4e51e393a2 updated ui/dist 2023-11-03 05:50:49 +02: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 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 94253f0dd5 updated the supported non-cgo build targets list 2023-10-16 20:27:37 +03:00
Kunal Singh 6cfaf343ac [#3531] updated README.md and CONTRIBUTING.md formatting 2023-10-16 20:18:05 +03:00
Gani Georgiev 9c562294ff set a default id column width and updated ui dist 2023-10-15 14:40:16 +03:00
Gani Georgiev 3c5409d607 updated changelog 2023-10-15 14:17:09 +03:00
Gani Georgiev 8868fa9ae6 use a custom tinymce svelte component and other minor optimizations 2023-10-15 14:04:44 +03:00
Gani Georgiev c0fa53a2ab check the mime type of the collections file field and updated field styles to minimize the layout shifts 2023-10-15 06:49:32 +03:00
Gani Georgiev 007b6a04ff updated dependencies and regenerated jsvm types 2023-10-14 23:28:32 +03:00
Gani Georgiev 731383a915 added .cmd() as alias for .exec() 2023-10-14 20:08:21 +03:00
Gani Georgiev 866d38caf9 updated jsvm types and removed unused helper 2023-10-14 19:14:27 +03:00
Gani Georgiev 1f6ab24b34 updated replaceQueryParams to use the last ? 2023-10-14 15:11:32 +03:00
Gani Georgievandthisni1s 01e33c07fe [#3364] added mailcow OAuth2 provider
Co-authored-by: thisni1s <nils@jn2p.de>
2023-10-14 14:52:35 +03:00
Gani Georgiev 69983bff5e removed legacy fonts 2023-10-12 23:34:34 +03:00
Gani Georgiev 2567659696 dragline z-index fix 2023-10-10 21:38:15 +03:00
Gani Georgiev 3e487f7e9d updated api preview docs 2023-10-09 21:03:02 +03:00
Gani Georgiev ca1a395628 minor styles adjustments 2023-10-09 19:55:53 +03:00
Gani Georgiev 1a47c70ccf Added support to manually resize the collections sidebar 2023-10-09 16:11:49 +03:00
Gani Georgiev 1f4bdfb867 [#3112] added options to pin collections 2023-10-09 14:26:56 +03:00
Gani Georgiev eae16cc42c synced with master 2023-10-09 12:01:21 +03:00
Gani Georgiev 1527b5ea4f updated CHANGELOG 2023-10-08 23:43:58 +03:00
Gani Georgiev ba6e17b3be updated jsvm types 2023-10-08 23:26:23 +03:00
Gani Georgiev b8219af941 [#3476] added raw template function 2023-10-08 23:17:38 +03:00
Gani Georgiev 8865cc1431 renamed record upsert local requestInfo to requestData to distinguish better from models.RequestInfo 2023-10-08 22:52:14 +03:00
Gani Georgiev 20b6ce4b84 excluded expand from the record draft and applied some lint fields alignment suggestions 2023-10-08 15:22:03 +03:00
Gani Georgiev e2f806d8bb added jsvm subscriptions.Message binding 2023-10-07 16:11:38 +03:00
Gani Georgiev 49e3f4ad93 [#3447] added jsvm http.Cookie binding 2023-10-07 15:35:20 +03:00
Gani Georgiev 6d672348e7 rearanged the DefaultClient struct fields to reduce its size from ~72 to ~32 bytes 2023-10-07 13:17:32 +03:00
Gani Georgiev 80d774a8ef [#3461] removed content-type charset and deprecated keep-alive header field 2023-10-07 12:57:07 +03:00
Gani Georgiev 5a5125383a Merge branch 'master' into develop 2023-10-05 09:33:46 +03:00
Gani Georgiev 7fa1ff53c9 trim view query semicolon chars and allow single quotes for column aliases 2023-10-05 09:31:24 +03:00
Gani Georgiev 0f4e27a11f updated nonempty label styles 2023-10-04 10:19:00 +03:00
Gani Georgiev 9997223923 fixed comment 2023-10-04 01:27:50 +03:00
Gani Georgiev 632ade795f updated file picker thumbs size 2023-10-03 16:18:32 +03:00
Gani Georgiev 91bd739b71 load only records with non-empty file fields and fupdated files list styles 2023-10-03 15:00:45 +03:00
Gani Georgiev 957064d70b extract the thumb sizes only from the selected file field 2023-10-03 12:51:55 +03:00
Gani Georgiev 609792a355 added records file picker support for the editor field 2023-10-03 10:36:46 +03:00
Gani Georgiev 2f5cfcfe87 replaced interface{} with any 2023-10-01 18:45:27 +03:00
Gani Georgiev 5732bc38e3 reload the records counter and remove drafts failures from LocalStorage 2023-10-01 15:57:20 +03:00
Gani Georgiev d69181cfef added helper class to disable the tabs animation to avoid the flickering 2023-10-01 15:56:29 +03:00
Gani Georgiev 8908d03b8c added support for linking to the record preview/update form and some other minor improvements 2023-10-01 12:55:30 +03:00
Gani Georgiev ebf73f5602 updated ui/dist 2023-09-30 14:43:12 +03:00
Gani Georgiev 8416f03bcf show local date on hover 2023-09-30 12:13:00 +03:00
Gani Georgiev 5d87385170 synced with master 2023-09-30 10:20:57 +03:00
Gani Georgiev 837134559f updated changelog 2023-09-30 09:37:26 +03:00
Gani Georgiev fadd12cd22 update tygoja and the generated jsvm typings 2023-09-28 23:04:34 +03:00
Gani Georgiev 469769d270 updated go deps 2023-09-25 23:30:10 +03:00
Gani Georgiev e4b7303a5d synced with master 2023-09-25 23:26:07 +03:00
Gani Georgiev e1fb5d26a5 [#3382] replaced filepath with path when extracting the filekey parent prefix 2023-09-25 22:47:48 +03:00
Gani Georgiev 4f396ca439 synced with master 2023-09-24 11:57:45 +03:00
Gani Georgiev ff08fc0fa4 remove the created and updated fields from the view API Preview and listings if the query doesn't have them 2023-09-24 11:27:10 +03:00
Gani Georgiev 4b511475ff updated go deps 2023-09-24 11:07:35 +03:00
Gani Georgiev 2550a9de54 [#3344, #2505] optimized records listing 2023-09-24 11:05:12 +03:00
Gani Georgiev 0f5dad7ede synced with master 2023-09-22 21:24:46 +03:00
Gani Georgiev d0b1c9d998 updated the invalid rel ids reactivity handling 2023-09-22 18:40:00 +03:00
Gani Georgiev fd9e120434 updated go deps and regenerated jsvm types 2023-09-22 18:24:34 +03:00
Gani Georgiev 92731ddd50 [#3372] fixed Admin UI listing error on invalid record relation 2023-09-22 18:19:05 +03:00
Gani Georgiev 4b4aaf2112 use goccy/go-json to speedup serialization 2023-09-18 22:52:36 +03:00
Gani Georgiev 6013d14bc6 added support for :excerpt(max, withEllipsis?) fields modifier 2023-09-18 15:20:10 +03:00
Gani Georgiev f3bcd7d3df added tokenizer.IgnoreParenthesis() to allow ignoring the parenthesis characters boundary checks 2023-09-17 12:14:57 +03:00
Gani GeorgievandGHOST 71f9be3cb0 [#3323] added Patreon OAuth2 provider
Co-authored-by: GHOST <ghostdevbusiness@gmail.com>
2023-09-16 08:20:49 +03:00
Gani Georgiev f605521208 updated js types docs 2023-09-16 07:07:35 +03:00
Gani Georgiev 4927583790 updated changelog and go deps 2023-09-16 06:54:19 +03:00
Gani Georgiev 6e80cb8136 added more descriptive internal password reset error message 2023-09-15 20:45:28 +03:00
Gani Georgiev bb0a2dd698 [#3310] added headers and cookies fields to the .send result 2023-09-14 14:47:47 +03:00
Gani Georgiev 2608efb56c added array fallback in case of missing joinNonEmpty items 2023-09-12 19:57:42 +03:00
Gani Georgiev eb2aa1cfc6 [#2197] added escape character support for the select field options 2023-09-12 10:29:54 +03:00
Gani Georgiev e1528aedac updated migration comment 2023-09-10 18:33:27 +03:00
Gani Georgiev 22b0a2b586 updated changelog 2023-09-10 10:57:51 +03:00
Gani Georgiev 0ca86a0c87 [#3273] added readerToString() JSVM helper 2023-09-10 10:46:19 +03:00
Gani Georgiev b2c8f394af fixed changelog typo 2023-09-09 12:29:09 +03:00
Gani Georgiev 56b2641469 added hmac jsvm primitives and updated docs 2023-09-09 12:03:34 +03:00
Gani Georgiev f266621a0f updated go deps 2023-09-06 14:13:02 +03:00
Gani Georgiev ca136c5dc1 [#3265] silent the localStorage quota error to prevent breaking the record form panel 2023-09-06 14:11:58 +03:00
Gani Georgiev abfe18bcce [#3261] exclude the local temp dir from the backups 2023-09-06 07:09:28 +03:00
Gani Georgiev f3fc7f78d7 updated changelog 2023-09-05 11:54:09 +03:00
Gani Georgiev 26fd069d11 updated npm deps 2023-09-05 11:49:01 +03:00
Gani Georgiev 62bde9e1f3 updated jsvm types 2023-09-05 11:25:14 +03:00
Gani Georgiev b945cd2fdf updated changelog and ui/dist 2023-09-05 11:23:41 +03:00
Gani Georgiev 89a0520f7d [#3257] normalize pasted text in the editor field 2023-09-05 10:36:42 +03:00
Gani Georgiev a88b3c5db3 updated api docs and enabled paste_as_text editor option 2023-09-05 08:06:21 +03:00
Gani Georgiev a37d9cfb84 updated ui/dist 2023-09-04 11:35:39 +03:00
Gani Georgiev 5b084bbbfd fixed grammar 2023-09-01 14:28:49 +03:00
Gani Georgiev 322508f6d1 registered a custom Deflate compressor to speedup the backups generation 2023-09-01 14:27:23 +03:00
Gani Georgiev 78e70bd52b there is no need to nil the app.settings on ResetBootstrapState 2023-09-01 13:46:33 +03:00
Gani Georgiev 58401459bf updated ui/dist 2023-09-01 12:44:43 +03:00
Gani Georgiev f172754775 removed cgo build archives artifacts 2023-09-01 12:41:49 +03:00
Gani Georgiev 31670ab3e1 log cron job errors 2023-09-01 11:17:09 +03:00
Gani Georgiev baacf4913b updated api preview tabs style 2023-09-01 09:30:49 +03:00
Gani Georgiev 8a94ccea42 updated to Svelte 4 2023-09-01 09:22:49 +03:00
Gani Georgiev b2bab9573a removed forgotten svg icon font declaration 2023-08-30 20:39:39 +03:00
Gani Georgiev e5b5c1f76f added option to auto generate admin and auth record passwords from the Admin UI 2023-08-30 14:59:00 +03:00
Gani Georgiev ccb1c42220 updated jsvm types and removed unnecessary comment 2023-08-29 22:31:33 +03:00
Gani Georgiev a394777264 [#3191] added client-side validation and syntax highlight for the json field 2023-08-29 22:10:57 +03:00
Gani Georgiev 7d10b3c502 updated go deps 2023-08-29 19:23:01 +03:00
Gani Georgiev 64ffb308bb use singular NoDecimal option name 2023-08-29 18:41:20 +03:00
Gani Georgiev 916c74c218 [#3113] added NoDecimal number field option 2023-08-29 18:35:57 +03:00
Gani Georgiev 17974d534e added ellipsis for long backup titles 2023-08-28 21:05:35 +03:00
Gani Georgiev bde7a86b30 [#3066] added the application name as part of the autogenerated backup name for easier identification 2023-08-28 20:36:55 +03:00
Gani Georgiev f7f8f09336 [#2599] added option to upload a backup file from the Admin UI 2023-08-28 20:06:48 +03:00
Gani Georgiev 2a6b891a9b merged with master 2023-08-26 14:46:30 +03:00
Gani Georgiev b4cb35483b updated jsvm types 2023-08-26 14:43:55 +03:00
Gani Georgiev 1606cfd6e2 fixed cronAdd example 2023-08-26 14:34:47 +03:00
Gani Georgiev 08f97ef0bb Merge branch 'master' into develop 2023-08-26 10:38:13 +03:00
Gani Georgiev 0dc263a40c updated go deps and use the new fileblob NoTempDir option 2023-08-26 10:37:12 +03:00
Gani Georgiev 824031e1a4 updated changelog 2023-08-26 10:30:26 +03:00
Gani Georgiev 311bc74b7e [#3025] updated tests.ApiScenario fields 2023-08-25 22:14:04 +03:00
Gani Georgiev 4f3d1682de synced with master 2023-08-25 18:11:09 +03:00
Gani Georgiev 18728732b9 updated ui/dist and go deps 2023-08-25 16:49:59 +03:00
Gani Georgiev bb4f27cfb5 updated automigrate template test 2023-08-25 16:47:41 +03:00
impact-merlinmarekandMerlin Marek d423acad3b [#3192] fixed autogenerated down migration not preserving old rules state
Co-authored-by: Merlin Marek <merlin.marek@posteo.net>
2023-08-25 16:21:04 +03:00
Gani Georgiev ef73052546 added httpAddr default when domain name is missing 2023-08-25 12:06:24 +03:00
Gani Georgiev c89c68a4dc poc of serve domain args 2023-08-25 11:16:31 +03:00
Gani Georgiev 02495554cf [#3175] added jsvm crypto primitives 2023-08-24 11:25:00 +03:00
Gani Georgiev cdbe6d78d3 added basic fields wildcard support 2023-08-23 20:56:38 +03:00
Gani Georgiev ff6904f1f8 removed unnecessary test cases prefix 2023-08-23 16:50:43 +03:00
Gani Georgiev bc0222dcb4 [#3176] skip fields query param transformations for non 20x responses 2023-08-23 16:49:09 +03:00
Gani Georgiev 04826ba588 reduced the default prewarmed goja vms to 25 2023-08-22 22:09:33 +03:00
Gani Georgiev 6ca1f5c431 use crypto if available 2023-08-22 22:01:05 +03:00
Gani Georgiev 2863763a27 added option to control the default TinyMCE urls convert behavior 2023-08-22 14:39:21 +03:00
Gani Georgiev 9c0d952543 fixed isNew checks 2023-08-22 13:01:08 +03:00
Gani Georgiev ed4f7c7358 updated presentable fields sorting 2023-08-22 12:29:54 +03:00
Gani Georgiev 49f1c869c0 synced with master 2023-08-22 10:54:22 +03:00
Gani Georgiev 5e6949062f updated changelog 2023-08-21 18:15:34 +03:00
Gani Georgiev dc063b20fa bumped app version 2023-08-21 18:14:23 +03:00
Gani Georgiev f42bbfd927 updated go deps 2023-08-21 18:10:07 +03:00
Gani Georgiev f0af24d78f use the presentable prop when displaying relations 2023-08-21 18:06:35 +03:00
Gani Georgiev 26fd3d48df added migration to copy existing DisplayFields to the new Presentable field 2023-08-21 12:58:51 +03:00
Gani Georgiev 864bbe7e12 added SchemaField.Presentable field 2023-08-21 12:58:18 +03:00
Gani Georgiev 1e995552c8 updated apis.Serve godoc 2023-08-20 18:31:56 +03:00
Gani Georgiev 6baae97b5d added json marshal fallback for complex structs as placeholder param 2023-08-19 16:33:45 +03:00
Gani Georgiev bcfbbc53f8 [#3147] don't silence connectivity errors 2023-08-18 19:14:13 +03:00
Gani Georgiev 8a916cd636 added datetime macros 2023-08-18 08:48:33 +03:00
Gani Georgiev 75f58a28ac added placeholder params support for Dao.FindRecordsByFilter and Dao.FindFirstRecordByFilter 2023-08-18 06:31:14 +03:00
Gani Georgiev e87ef431c5 added jsvm .* binds 2023-08-17 20:50:00 +03:00
Gani Georgiev b2ac538580 [#3097] added SmtpConfig.LocalName option 2023-08-17 19:07:56 +03:00
Gani Georgiev 53b20ec104 updated LastVerificationSentAt and LastResetSentAt fill sequence 2023-08-17 14:03:11 +03:00
Gani Georgiev c8ef3c4050 updated ui/dist 2023-08-16 22:38:56 +03:00
Gani Georgiev 9113d30103 fixed typo 2023-08-16 17:38:08 +03:00
Gani Georgiev fef6e584b7 updated jsvm types and godoc list formatting 2023-08-15 12:35:52 +03:00
Gani Georgiev 67fa47b1bb [#3132] updated godoc 2023-08-15 12:25:24 +03:00
Gani Georgiev 5f21c4119f [#3132] added common cron expression macros 2023-08-15 12:21:33 +03:00
Gani Georgiev 734f35c504 synced with master 2023-08-15 12:10:21 +03:00
Gani Georgiev 038ae8f803 updated changelog 2023-08-15 01:14:10 +03:00
Gani Georgiev 2236288e57 quoted the wrapped view query columns 2023-08-15 01:09:53 +03:00
Gani Georgiev 8f10c66160 updated ui/dist 2023-08-15 00:58:34 +03:00
Gani Georgiev 5960dc5f2d removed js sdk dto helpers 2023-08-14 21:20:49 +03:00
Gani Georgiev cbf1215bb1 updated jsvm types 2023-08-11 14:40:25 +03:00
Gani Georgiev 1b633720be updated views migrations to use SaveCollection 2023-08-11 14:37:53 +03:00
Gani Georgiev adb5d6e998 [#3110] normalized view queries with numeric or expression ids 2023-08-11 14:29:18 +03:00
Gani Georgiev 3841946b61 downgraded temp gocloud until the os.NoTempDir is released 2023-08-10 21:38:13 +03:00
Gani Georgiev 369e2703c2 updated ui/dist 2023-08-10 08:56:26 +03:00
Gani Georgiev 4a45ad91fa [#3106] always refresh the Admins UI initial admins counter cache when there are none 2023-08-10 08:50:48 +03:00
Nikita Zhenev 265dac45ce [#3103] fixed jsvm registerMigrations error message typo 2023-08-09 18:40:34 +03:00
Gani Georgiev f152787578 updated changelog 2023-08-09 13:17:24 +03:00
Gani Georgiev 640623f4fd [#3098] fixed incorrect cascade delete tooltip message 2023-08-09 12:58:20 +03:00
Gani Georgiev 1aff89f377 use the logs maxDays before firing the goroutine 2023-08-09 12:23:49 +03:00
Gani Georgiev 7d6b12a4ef updated ui/dist 2023-08-08 14:33:08 +03:00
Gani Georgiev 7a3223e415 [#3089] use a temp dir inside pb_data to prevent backups cross-device link error 2023-08-08 14:15:29 +03:00
Gani Georgiev bd18688f35 [#3090] fixed relation to view error message 2023-08-08 12:41:51 +03:00
Gani Georgiev f90da96820 enabled lazy loading for the Admin UI thumb images 2023-08-06 21:51:55 +03:00
Gani Georgiev 6c8f2d2cd6 use scrollbar-gutter to minimize the table records listing layout shifts 2023-08-06 21:44:26 +03:00
Gani Georgiev 5e84305922 fixed changelog grammar 2023-08-05 10:12:22 +03:00
Gani Georgiev b3421861e6 updated jsvm types 2023-08-05 09:51:00 +03:00
Gani Georgiev 872492ad22 updated changelog 2023-08-05 07:26:53 +03:00
Gung JodiandGung Jodi 5c14c7cf5e [#3068] fixed RequestData log deprecation note
Co-authored-by: Gung Jodi <agung.pratama@dana.id>
2023-08-05 07:24:20 +03:00
Gani Georgiev b59f0f418e updated ui/dist 2023-08-03 12:42:00 +03:00
Gani Georgiev 06d3e27e03 [#3054] added core.RealtimeConnectEvent.IdleTimeout field 2023-08-03 12:38:02 +03:00
Gani Georgiev b1093baef7 [#3058] soft-deprecated 'data' prop in favour of 'body' to allow raw strings 2023-08-03 12:32:04 +03:00
Gani Georgiev b3f09ff045 updated changelog and go deps 2023-07-31 22:47:35 +03:00
Gani Georgiev b33ad36f64 renamed variable name 2023-07-31 17:48:26 +03:00
Gani Georgiev 9254ce46eb trigger the jsvm cron ticker only on app serve 2023-07-31 14:18:59 +03:00
Gani Georgiev cc8c855306 updated ui/dist 2023-07-31 13:09:39 +03:00
Gani Georgiev b74994b906 [#3026] use relative path for the oauth2 provider page link 2023-07-31 13:07:30 +03:00
Gani Georgiev f652dc71bb [#3025] manually trigger the OnBeforeServe hook for tests.ApiScenario 2023-07-31 12:27:22 +03:00
Gani Georgiev 6d2677a5e3 fixed cronRemove docs declaration 2023-07-30 18:02:16 +03:00
Gani Georgiev 0c5305c174 fixed incomplete sentence in the changelog 2023-07-30 16:20:58 +03:00
Gani Georgiev 5398576f4f updated changelog formatting 2023-07-30 15:22:10 +03:00
Gani Georgiev 3d1f570b38 updated jsvm types 2023-07-30 14:12:35 +03:00
Gani Georgiev fa057502f1 updated ui/dist deps 2023-07-30 14:10:42 +03:00
Gani Georgiev bb4a5cfe83 updated ui/dist and some lint warnings 2023-07-30 13:40:22 +03:00
Gani Georgiev ac1fd74942 updated tests 2023-07-30 10:02:44 +03:00
Gani Georgiev db660ac780 revert the default max perPage limit to 500 for now 2023-07-29 21:44:31 +03:00
Gani Georgiev cdeb9a94ed added action arg to the before Dao hook to allow skipping the default persist behavior 2023-07-29 19:52:36 +03:00
Gani Georgiev 6da94aef8d updated jsvm panic handling when HooksWatch is set 2023-07-29 16:01:53 +03:00
Gani Georgiev 0a4fdc17a5 enabled tokens binds and removed primitive constructors overwrites 2023-07-29 13:56:31 +03:00
Gani Georgiev 1bbba7a0ae added cron expression UTC timezone note 2023-07-28 22:24:21 +03:00
Gani Georgiev fcc4e305e0 removed unnecessary large timeout (for reordering the queue 0 should be enough) 2023-07-27 16:10:59 +03:00
Gani Georgiev 854796a8dd [#3000] disallowed relations to views from non-view collections 2023-07-27 15:57:20 +03:00
Gani Georgiev e6a41773ca [#2588] added warning message in case the update command is run in a Docker container or NixOS 2023-07-26 13:18:59 +03:00
Gani Georgiev f4a6d8af49 excluded unnecessary types to reduce the size of the generated declarations file 2023-07-26 10:45:06 +03:00
Gani Georgiev 1563855251 [#2992] added migration to reset already inserted null values 2023-07-26 00:40:48 +03:00
Gani Georgiev 1330e2e1e7 [#2992] fixed zero-default value not being used if the field is not explicitly set when manually creating records 2023-07-25 20:37:19 +03:00
Gani Georgiev 34fe55686d wrapped tests.ApiScenario execution in a subtest 2023-07-25 13:37:43 +03:00
Gani Georgiev b0aa387235 removed extra param unescaping as it was fixed in echo 2023-07-25 13:36:57 +03:00
Gani Georgiev c3f7aeb856 register LoadAuthContext as Pre so that the auth context is aavailable other Pre middlewares 2023-07-25 12:45:41 +03:00
Gani Georgiev 54a6ae6710 updated record.PublicExport comment 2023-07-25 06:11:50 +03:00
Gani Georgiev 8dfc90985b added native echo.HandlerFunc support and .staticDirectoryHandler bind 2023-07-24 21:11:55 +03:00
Gani Georgiev 99ea916c14 renamed expand fetchFunc args to optFetchFunc and updated jsvm types 2023-07-24 16:59:13 +03:00
Gani Georgiev 70151a3c19 added bindings 2023-07-24 16:39:11 +03:00
Gani Georgiev 543fb350ec added jsvm .* helpers 2023-07-24 13:59:13 +03:00
Gani Georgiev ea4e3128ca updated jsvm types 2023-07-24 12:45:23 +03:00
Gani Georgiev ae8cbc8f45 added template.Registry.LoadFS method 2023-07-24 12:33:46 +03:00
Gani Georgiev cb156e1903 increased the default sqlite cache_size to 16mb 2023-07-24 10:35:42 +03:00
Gani Georgiev edcb6950e5 watch pb_hooks subdirectories 2023-07-23 23:45:41 +03:00
Gani Georgiev 085fb1601e added jsvm binding 2023-07-23 16:43:38 +03:00
Gani Georgiev 132a8c0aab added template.Registry.LoadString test 2023-07-23 15:48:01 +03:00
Gani Georgiev 4f3ca6fe2b added helper html template rendering utils 2023-07-23 15:37:30 +03:00
Gani Georgiev 13c0572fe1 updated jsvm types 2023-07-22 19:01:20 +03:00
Gani Georgiev fda4b67dbc updated ui/dist 2023-07-22 18:59:45 +03:00
Gani Georgiev aefbccbfea replaced os.IsNotExists 2023-07-22 18:59:33 +03:00
Gani Georgiev d1336da339 make use of skipTotal 2023-07-22 18:50:40 +03:00
Gani Georgiev f453cefc0b updated go.mod and jsvm types 2023-07-21 23:36:37 +03:00
Gani Georgiev b6bc09fee1 updated jsvm types 2023-07-21 23:29:01 +03:00
Gani Georgiev 437843084b added search skipTotal support 2023-07-21 23:24:36 +03:00
Gani Georgiev 1e4c665b53 [#2957] added support for wrapped api errors 2023-07-20 22:01:58 +03:00
Gani Georgiev ac52befb5b changed subscription.Message.Data to []byte and added client.Send(m) helper 2023-07-20 21:25:13 +03:00
Gani Georgiev 50d7df45eb added ?download file serve query param support to force file download 2023-07-20 15:04:26 +03:00
Gani Georgiev 7e0a4e61b4 updated ui/dist 2023-07-20 14:33:45 +03:00
Gani Georgiev 689ad644c1 updated npm deps 2023-07-20 13:16:16 +03:00
Gani Georgiev f660707712 added e.action to the realtime docs preview 2023-07-20 13:10:23 +03:00
Gani Georgiev 06016722d1 removed legacy 404 check and preserved collections sort order within each type group 2023-07-20 13:06:49 +03:00
Gani Georgiev 832d7f360c updated tinymce 2023-07-20 12:00:23 +03:00
Gani Georgiev 939653ecc0 added after hooks error response tests 2023-07-20 11:42:57 +03:00
Gani Georgiev 610a948dcc added Response.Committed checks 2023-07-20 10:40:03 +03:00
Gani Georgiev b2284b5f4b updated OnModel hooks comment for consistency with the site docs 2023-07-19 18:23:39 +03:00
Gani Georgiev d9e1a759a1 make use of the after hook finalizer 2023-07-18 15:31:36 +03:00
Gani Georgiev 624b443f98 removed unnecessary collection queries 2023-07-18 13:41:14 +03:00
Gani Georgiev 71a70bac9d updated jsvm errors handling 2023-07-18 12:36:04 +03:00
Gani Georgiev 0110869c89 soft deprecated apis.RequestData(c) in favor of apis.RequestInfo(c) and updated jsvm bindings 2023-07-17 23:13:39 +03:00
Gani Georgiev 7d4017225c synced with master 2023-07-17 13:17:17 +03:00
Gani Georgiev 94a1cc07d5 [#2930] added extra normalizations to ensure that newly created multiple fields has the correct zero-default for already inserted records 2023-07-17 11:38:19 +03:00
Gani Georgiev 1720c82570 updated comment 2023-07-17 00:08:06 +03:00
Gani Georgiev 81bd1a1732 reset the requestData Admin and AuthRecord fields 2023-07-17 00:05:15 +03:00
Gani Georgiev f421da4b9b use Dao.CanAccessRecord when checking for protected file access 2023-07-17 00:03:09 +03:00
Gani Georgiev 3eaa3ca1b5 fixed .send binding tests 2023-07-16 23:38:49 +03:00
Gani Georgiev 2d1ad16b4f updated cron jsvm bindings and generated types 2023-07-16 23:24:10 +03:00
Gani Georgiev 6179864828 return the http.Server instance to allow manual shutdowns 2023-07-16 23:13:15 +03:00
Gani Georgiev 64d7ab22f3 treat returned false bool from a jsvm hook as hook.stopPropagation 2023-07-14 16:50:35 +03:00
Gani Georgiev 4962dc618b added record.ExpandedOne(rel) and record.ExpandedAll(rel) helpers 2023-07-14 15:21:59 +03:00
Gani Georgiev 8e2246113a synced with master 2023-07-14 12:44:26 +03:00
Gani Georgiev b9993aaa73 updated changelog 2023-07-14 12:16:56 +03:00
Gani Georgiev f77fb0cc1c updated tests with some clarification code comments 2023-07-14 12:13:44 +03:00
Gani Georgiev 460cc35bb6 updated ui/dist 2023-07-14 12:01:48 +03:00
Gani Georgiev f0bcffec8b [#2914] register the eagerRequestDataCache middleware only for the api grroup to avoid conflicts with custom routes 2023-07-14 11:55:29 +03:00
Gani Georgiev 2b465b0646 load a default fetchFunc for dao.ExpandRecord(s) 2023-07-14 08:36:01 +03:00
Gani Georgiev fdccdcebad added option to call Dao.RecordQuery() with the collection id or name 2023-07-13 22:38:55 +03:00
Gani Georgiev a38bd5bedc tests types.d.ts in gitignore 2023-07-12 17:31:26 +03:00
Gani Georgiev 6fe04bd280 returned OnAfterBootstrap error and added more jsvm tests 2023-07-12 17:12:45 +03:00
Gani Georgiev d0a68da7e7 fixed jsvm docs path 2023-07-11 18:19:33 +03:00
Gani Georgiev ede67dbc20 added jsvm bindings and updateing the workflow to generate the jsvm types 2023-07-11 18:09:55 +03:00
Gani Georgiev 3d3fe5c614 updated Dao.CanAccessRecord to return the invalid filter or db error 2023-07-11 11:50:10 +03:00
Gani Georgiev 7bb33d4453 updated Application URL input label for consistency 2023-07-09 16:44:29 +03:00
Gani Georgiev 0ad4dbc65a synced with master 2023-07-08 21:29:21 +03:00
Gani Georgiev c3844250e8 updated go deps 2023-07-08 20:06:34 +03:00
Gani Georgiev c293994d2b added hooksPool flag and updated doc comments 2023-07-08 20:02:03 +03:00
Gani Georgiev a557aa35f5 updated readme and Find*ByFilter godoc comment 2023-07-08 14:01:02 +03:00
Gani Georgiev 736e9673ad updated generated types 2023-07-08 13:59:24 +03:00
Gani Georgiev 13d96e793b (no tests) updated jsvm bindings 2023-07-08 13:51:16 +03:00
Gani Georgiev 5e37c90dde added cron.Total method 2023-07-08 13:51:00 +03:00
Gani Georgiev 7bcd00a87e synced with master 2023-07-06 23:38:37 +03:00
Gani Georgiev ebfbb55f91 allow no space between the index table name and columns list 2023-07-06 23:20:31 +03:00
Gani Georgiev d77479131a [#2868] fixed unique validator detailed error message not being returned when camelCase field name is used 2023-07-06 23:14:18 +03:00
Gani Georgiev 8ef00efe84 allow no space between the index table name and columns list 2023-07-06 15:47:16 +03:00
Gani Georgiev a4101f7670 synced with master 2023-07-03 20:53:09 +03:00
Gani Georgiev 08b4fc20a9 fixed changelog typo 2023-07-03 19:46:40 +03:00
Gani Georgiev 9ec01d74d8 optimized search count queries to use rowid by default 2023-07-03 17:57:23 +03:00
Gani Georgiev 320f990f84 [#2818] fixed text field regex pattern example 2023-06-30 19:46:00 +03:00
Gani Georgiev 7297f55ca4 [#2817] allowed 0 as RelationOptions.MinSelect value 2023-06-30 18:13:56 +03:00
Gani Georgiev 2cb642bbf7 aliased and soft-deprecated NewToken with NewJWT, added encrypt/decrypt goja bindings and other minor doc changes 2023-06-28 22:56:03 +03:00
Gani Georgiev ecdf9c26cd added comments and typedoc group tags to the generated docs 2023-06-28 21:39:57 +03:00
Gani Georgiev a672ab959f merged jsvm migrations and hooks and updated the ambient TS types location 2023-06-27 14:45:04 +03:00
Gani Georgiev 1571ebe4eb use microseconds when inserting the auto generated migration 2023-06-27 00:35:17 +03:00
Gani Georgiev b8bb5e8d72 fixed migrate down not returning the correct migrations order when the stored applied time is in seconds 2023-06-27 00:33:31 +03:00
Gani Georgiev af77554250 enabled baseBinds for the goja migrations 2023-06-26 23:17:37 +03:00
Gani Georgiev 3b68782cfb synced with master 2023-06-26 18:21:49 +03:00
Gani Georgiev 1679c88e6d updated binder test cases 2023-06-26 13:04:15 +03:00
Gani Georgiev 91bbbc4bdb added eager empty string check 2023-06-26 12:53:15 +03:00
Gani Georgiev 4ab9c6f87f updated ui/dist 2023-06-26 12:50:21 +03:00
Gani Georgiev 68157a3a65 added missing array value normalization 2023-06-26 12:47:56 +03:00
Gani Georgiev e6cf4ad2ef updated go deps 2023-06-26 12:44:14 +03:00
Gani Georgiev edd2eaae88 added negative number test 2023-06-26 12:42:53 +03:00
Gani Georgiev 2e8e835a68 updated ui/dist 2023-06-26 12:33:55 +03:00
Gani Georgiev 9cba6ac386 [#2763] fixed multipart/form-data array value bind 2023-06-26 12:30:51 +03:00
Gani Georgiev 8388e36f28 updated readme note 2023-06-26 11:03:54 +03:00
Gani Georgiev 051b3702b0 replaced DynamicList with a more generic (model) helper to allow creating pointer slice of any type 2023-06-25 20:19:12 +03:00
Gani Georgiev 39accdba58 updated go deps 2023-06-23 22:26:05 +03:00
Gani Georgiev 32de0aa40a use direct string comparison in the ApiError message test 2023-06-23 22:23:03 +03:00
Gani Georgiev 9bfcdd086a replaced .* errors with constructors and added apisBinds tests 2023-06-23 22:20:13 +03:00
Gani Georgiev 1d20124467 updated changelog 2023-06-23 14:15:53 +03:00
Gani GeorgievandValentine 435eca6f35 [#2762] added Yandex OAuth2 provider
Co-authored-by: Valentine <xb2w1z@gmail.com>
2023-06-23 14:13:43 +03:00
Gani Georgiev 0a61db6efd add @todo note to the RequestData struct 2023-06-23 13:38:13 +03:00
Gani Georgiev 7ab8405946 removed goja middlewares that don't make much sense in the goja context 2023-06-23 12:54:08 +03:00
Gani Georgiev 6fa3e99be2 use inflector.UcFirst instead of strings.Title 2023-06-22 21:54:39 +03:00
Gani Georgiev 3160fb2d99 added DynamicModel form tag and removed unused helper 2023-06-22 16:32:21 +03:00
Gani Georgiev 1cbf16b3bf ucfirst the DynamicModel field name so that we can use later the same FieldMapper resolver rules 2023-06-22 16:29:58 +03:00
Gani Georgiev dad289b90d bind hooksWatch flag 2023-06-21 21:46:13 +03:00
Gani Georgiev c795ecd21e updated jsvm generated types 2023-06-21 20:40:43 +03:00
Gani Georgiev 21607f0f66 updated cobra.Command constructor and update structConstructor to use goja.Object.Set 2023-06-21 20:36:57 +03:00
Gani Georgiev fc311a8d28 removed the temp len binding as the issue was fixed in goja#521 2023-06-21 13:40:59 +03:00
Gani Georgiev 93606c6647 added DynamicModel and DynamicList goja bindings 2023-06-21 11:21:54 +03:00
Gani Georgiev 1adcfcc03b adde json map Get and Set helpers 2023-06-20 22:57:51 +03:00
Gani Georgiev ed4304dc30 added jsvm typings and docs generation 2023-06-20 08:54:02 +03:00
Gani Georgiev c0a6a21b9e updated code comments and added some notes 2023-06-19 21:45:45 +03:00
Gani Georgiev a7bb599cd0 Merge branch 'master' into develop 2023-06-16 14:48:52 +03:00
Gani Georgiev bd94940eef updated changelog 2023-06-16 14:47:34 +03:00
Sven-Kristjan Kompus caf343ef9c [#2726] removed unnecessary Dao().TotalAdmins() call 2023-06-16 14:43:05 +03:00
Gani Georgiev 28ba4655c1 added note about the disk space when creating backups 2023-06-16 12:24:28 +03:00
Gani Georgiev bd95a5b74c [#2693] removed the implicit autosnapshot migration creation as it is not clear to the users when it happens 2023-06-14 13:21:37 +03:00
Gani Georgiev 745b230097 updated jsvm mapper and updated godoc formatting 2023-06-14 13:14:30 +03:00
Gani Georgiev ec303a60ed [#2271] added dao.CanAccessRecord() helper 2023-06-14 13:13:21 +03:00
Gani Georgiev e99b0627d6 synced with master 2023-06-10 23:43:09 +03:00
Gani Georgiev d1bcaa65b1 updated changelog 2023-06-10 23:34:32 +03:00
Gani Georgiev 2d60ad170e updated ui/dist 2023-06-10 23:32:41 +03:00
Simon Loir 0ba963a5d7 [#2681] fixed collection index column sort normalization 2023-06-10 23:30:32 +03:00
Gani Georgiev b77b6d1a18 updated ui/dist 2023-06-09 13:45:42 +03:00
Gani Georgiev 779b23d919 synced with master 2023-06-09 13:33:38 +03:00
Gani Georgiev 87a6f1bebb updated deps and removed unnecessary normalizeSort() call 2023-06-09 13:32:01 +03:00
Gani Georgiev aa5b5bb83e added default initial -created sort 2023-06-09 13:07:34 +03:00
Gani Georgiev af193d440d [#2675] fixed implicit relation display fields serialization 2023-06-09 12:57:17 +03:00
Gani Georgiev 7f06816008 normalized active sort on collection schema change 2023-06-09 12:56:07 +03:00
Gani Georgiev a5b27cce5c fixed apis.NewUnauthorizedError test 2023-06-08 18:16:00 +03:00
Gani Georgiev ebd6891471 updated broken tests 2023-06-08 18:14:01 +03:00
Gani Georgiev 3cf3e04866 restructered some of the internals and added basic js app hooks support 2023-06-08 17:59:08 +03:00
Gani Georgiev ff5508cb79 synced with master 2023-06-02 19:53:53 +03:00
Gani Georgiev e4a90f6605 updated npm deps and generated ui/dist 2023-06-02 19:41:13 +03:00
Gani Georgiev f07f7a1e35 synced with master 2023-06-02 19:38:36 +03:00
Gani Georgiev acbce42bff updated changelog 2023-06-02 19:33:20 +03:00
Gani Georgiev b33bebd4cd bumped app version 2023-06-02 19:32:26 +03:00
Gani Georgiev 881b625177 updated changelog 2023-06-02 19:23:43 +03:00
Gani Georgiev 4c2dcac61a added dao.WithoutHooks() helper 2023-06-01 15:42:38 +03:00
Gani Georgiev dcb00a3917 updated changelog 2023-05-31 21:51:01 +03:00
Gani Georgiev ddca49ba16 [#2309] added query by filter record helpers 2023-05-31 11:49:16 +03:00
Gani Georgiev 0fb92720f8 Merge branch 'master' into develop 2023-05-30 21:22:39 +03:00
Gani Georgiev 3122223a71 [#2602] added common int types support when scanning types.DateTime 2023-05-30 20:43:20 +03:00
Gani Georgiev 7de346b532 fixed realtime delete event to be called after the record was deleted from the db 2023-05-29 22:28:07 +03:00
Gani Georgiev 729f9f142e check after hook errors 2023-05-29 21:50:07 +03:00
Gani Georgiev 45b73e3dfb Merge branch 'master' into develop 2023-05-29 17:01:16 +03:00
Gani Georgiev dbbc1e25ca replaced multiple error wraps with plain %v for older go 1.18 and go 1.19 compatibility 2023-05-29 17:01:04 +03:00
Gani Georgiev d3711b0503 added new core.ServeEvent fields 2023-05-29 16:57:50 +03:00
Gani Georgiev 9d8df8d05d added option to remove single registered hook handler 2023-05-29 14:51:03 +03:00
Gani Georgiev 97f29e4305 synced with master 2023-05-28 23:57:19 +03:00
Gani Georgiev 732044f795 keep both original file and fallback errors 2023-05-28 22:00:48 +03:00
Gani Georgiev 9a1354ae62 [#2589] added .exe fallback to the selfupdate cmd and replaced path with filepath 2023-05-28 21:57:12 +03:00
Gani Georgiev fcfcaa0628 refresh the cached logged admin and auth record 2023-05-28 17:36:56 +03:00
Gani Georgiev d5314b028b synced with master 2023-05-27 14:12:34 +03:00
Gani Georgiev e3876c0e13 updated nocgo busy_timeout pragma 2023-05-27 09:37:21 +03:00
Gani Georgiev b1307c9041 registered custom cgo sqlite driver 2023-05-27 09:24:18 +03:00
Gani Georgiev dcdf43f0fc return conn.Exec error 2023-05-27 09:07:38 +03:00
Gani Georgiev f6a616b7e8 [#2570] fixed default PRAGMAs not being applied for new connections 2023-05-27 09:04:01 +03:00
Gani Georgiev 3be5875ea9 Merge branch 'master' into develop 2023-05-25 21:00:45 +03:00
Gani Georgiev 833a84b3d7 [#2570] use ON for consistency with the SQLite examples and updated collection delete test to check for _exterAuths references 2023-05-25 20:26:03 +03:00
Gani Georgiev 94680c41f7 synced with master 2023-05-24 23:31:31 +03:00
Gani Georgiev c72c951b24 [#2567] fixed schema fields sort not working on Safari/Gnome Web 2023-05-24 23:19:06 +03:00
Gani GeorgievandValentine af71b63f23 [#2533] added VK OAuth2 provider
Co-authored-by: Valentine <xb2w1z@gmail.com>
2023-05-24 15:41:58 +03:00
Gani Georgiev e40cf46b33 synced with master 2023-05-24 11:07:29 +03:00
Gani Georgiev e9969ed6d1 updated changelog 2023-05-24 09:34:23 +03:00
Gani Georgiev 511259ed10 fixed missing view id field error message typo 2023-05-24 09:25:46 +03:00
Gani Georgiev 6b16b7856b [#2551] auto register the initial generated snapshot migration to prevent incorrectly reapplying the snapshot on container restart 2023-05-24 09:23:10 +03:00
Gani Georgiev 5b330ab5b4 updated compareVersions tests 2023-05-23 23:15:56 +03:00
Gani Georgiev 7dcfa65146 updated ui/dist 2023-05-23 22:47:04 +03:00
Gani GeorgievandPedro Costa a6bb1bf096 [#2534] added Instagram OAuth2 provider
Co-authored-by: Pedro Costa <550684+pnmcosta@users.noreply.github.com>
2023-05-23 22:37:44 +03:00
Gani Georgiev 728427cecf Merge branch 'master' into develop 2023-05-23 21:56:23 +03:00
Gani Georgiev 231ddc9791 [#2541] removed file field dataTransfer.effectAllowed checks 2023-05-23 21:09:24 +03:00
Gani Georgiev 5bf2f692d8 fixed formatting 2023-05-23 21:08:22 +03:00
Gani Georgiev ce28a9af78 [#2548] use fileath.Clean on the fs.WalkDirFunc argument to ensure that the same normalizations are applied 2023-05-23 18:34:24 +03:00
Gani Georgiev db20e38cda updated backups test to ensure that the backups dir is not part of the generated zip 2023-05-23 16:47:58 +03:00
Gani Georgiev 651b439096 synced with master 2023-05-23 11:36:55 +03:00
Gani Georgiev 286046e15a improved update cmd version check 2023-05-23 11:35:54 +03:00
Gani Georgiev 7a41ff0127 added version check todo 2023-05-23 10:46:54 +03:00
Gani Georgiev 4440b5f817 updated changelog 2023-05-23 10:32:31 +03:00
Gani Georgiev d7745ba702 [#2541] fixed file field drag and drop not working in Firefox and Safari 2023-05-23 10:23:41 +03:00
Gani Georgiev 007ef1e152 [#2540] fixed missing CommonHelper references on Downloaad as JSON btn 2023-05-23 07:55:21 +03:00
Gani Georgiev a291cb5ca7 [#2535] avoid mutating the cached request data on OAuth2 user create 2023-05-22 23:59:36 +03:00
Gani Georgiev 86049ed048 updated changelog 2023-05-22 08:02:03 +03:00
Gani Georgiev 6457fc794f updated ui/dist 2023-05-22 07:59:56 +03:00
Gani Georgiev 0eecbd8289 updated app.RestoreBackup() godoc 2023-05-22 07:58:44 +03:00
David Schissler 6521decfa8 [#2526] fixed typo in BackupCreatePanel.svelte 2023-05-22 07:50:42 +03:00
Kunal Singh c370d84074 [#2527] removed unnecessary slice check 2023-05-22 07:48:22 +03:00
Gani Georgiev 4f5aa6ffda updated changelog and ui/dist 2023-05-21 21:06:20 +03:00
Frangu Madalin 8921712821 [#2523] fixed realtime dart api preview example 2023-05-21 21:00:24 +03:00
Gani Georgiev 1c63ae1324 [#2519] replace os.Rename with manually moving the dir children 2023-05-21 20:46:47 +03:00
Gani Georgiev cbaca91581 ignore the v prefix when comparing the tag and the current version 2023-05-21 13:00:12 +03:00
Gani Georgiev 5551f8f5aa eager update app settings and added isServe check for the auto backups 2023-05-21 11:47:05 +03:00
Gani Georgiev a56a04ed0e updated deps 2023-05-21 10:55:33 +03:00
Gani Georgiev 47bb0bc2d8 updated the verification api preview example 2023-05-20 23:53:04 +03:00
Gani Georgiev f49e90bd0b added fields query param to the api docs preview 2023-05-20 06:03:30 +03:00
Gani Georgiev 3820f3d7d7 updated page sidebar width styles 2023-05-19 13:48:58 +03:00
Gani Georgiev faaf19d3bb [#2510] added missing .length prop to the select searchable check 2023-05-19 13:46:30 +03:00
Gani Georgiev 6d0303deaf added meta.isNew to the json OAuth2 auth response 2023-05-18 00:19:54 +03:00
Gani Georgiev d36a044dc7 updated npm deps 2023-05-17 23:59:54 +03:00
Gani Georgiev 1cc67ef7e3 added sorting for the multiple relations form field 2023-05-17 23:54:06 +03:00
Gani Georgiev 1e2d1045b8 added title attrbite to the collection sidebar items and increased the default width 2023-05-17 23:33:43 +03:00
Gani Georgiev df5a291d20 use the filenames instead of indexes on delete 2023-05-17 23:17:45 +03:00
Gani Georgiev 04e0ad9b21 [#2445] added support for multiple files sort in the Admin UI 2023-05-17 22:41:42 +03:00
Gani Georgiev 24ab233376 added experimental update command 2023-05-17 21:14:12 +03:00
Gani Georgiev a42ab6a205 removed the legacy temp upgrade command 2023-05-17 18:39:15 +03:00
Gani Georgiev 472671fee1 fixed comment typo and updated default fallback displayable props 2023-05-16 09:35:33 +03:00
Gani Georgiev 6bde84131c [#2466] added accept file field attr 2023-05-13 22:23:19 +03:00
Gani Georgiev e8b4a7eb26 added backup apis and tests 2023-05-13 22:12:42 +03:00
Gani Georgiev 3b0f60fe15 added basic cron util 2023-05-09 22:36:40 +03:00
Gani Georgiev d3314e1e23 (untested!) added temp backup api scaffoldings before introducing autobackups and rotations 2023-05-08 21:52:40 +03:00
Gani Georgiev 60eee96034 synced with master 2023-05-05 08:49:21 +03:00
Gani Georgiev c6d5992442 updated changelog and ui/dist 2023-05-05 05:52:48 +03:00
Gani Georgiev a7f3805f87 [#2423] insert default settings params with the init migration 2023-05-05 05:22:00 +03:00
Gani Georgiev 92f21e3355 trigger the OnTerminate hook and use a buffered chan instead of wg 2023-05-04 15:12:28 +03:00
Gani Georgiev 4c57968d89 synced with master 2023-05-03 14:52:10 +03:00
Gani Georgiev b84ebabf4a [#2410] Fixed editor field fullscreen z-index 2023-05-03 14:37:19 +03:00
Gani Georgiev 90abe1612e added helper archive package to create and extract zips 2023-05-02 14:51:15 +03:00
Gani Georgiev dfabfa779e synced with master 2023-05-01 22:07:17 +03:00
Gani Georgiev 28c9f02cb4 updated js sdk and resolved isNew record field conflict 2023-05-01 13:00:12 +03:00
Gani Georgiev 67e1974228 updated js sdk 2023-04-28 16:22:38 +03:00
Gani Georgiev 0dcd332962 temporary change the base backend env url 2023-04-28 15:55:26 +03:00
Gani Georgiev f70b457507 updated ui/dist 2023-04-28 15:47:48 +03:00
Gani Georgiev c8fd831115 fixed changelog typos 2023-04-27 20:53:49 +03:00
Gani Georgiev beca0a044e changed X-Forwarded-For parsing to use the first non-empty leftmost-ish ip as it is more close to the 'real ip' 2023-04-27 20:52:08 +03:00
Gani Georgiev 9fa56b020c [#2372] use Fly-Client-IP header if available for the 'real' user ip 2023-04-27 20:29:59 +03:00
Gani Georgiev 429929dd0c added explicit readonly and disabled props to MultipleValueInput 2023-04-27 08:30:04 +03:00
Gani Georgiev ecf7d657c0 updated npm deps and added border to the expanded schema field 2023-04-26 22:15:41 +03:00
Gani Georgiev 4296148c87 updated ui/dist 2023-04-26 18:55:57 +03:00
Gani Georgiev bfb38ab51e added select readonly prop and updated the disabled schema field state 2023-04-26 18:35:34 +03:00
Gani Georgiev 1967dcfeba updated schema field options dropdown spacing 2023-04-26 18:04:08 +03:00
Gani Georgiev 5c95e9b109 [#2318] updated schema fields ui 2023-04-26 14:12:47 +03:00
Gani Georgiev 39c3a95a08 Merge branch 'master' into develop 2023-04-25 19:18:42 +03:00
Gani Georgiev 74306c8092 updated go deps 2023-04-25 18:31:27 +03:00
Gani Georgiev a5b3cc0f34 use relative oauth2 path redirect to support subpath proxy deployments 2023-04-25 18:29:36 +03:00
Gani Georgiev 4944884683 updated changelog 2023-04-25 18:11:04 +03:00
Gani Georgiev c0a7d0f6c0 added ?fields query parameter support to limit the returned api fields 2023-04-25 17:58:51 +03:00
Gani Georgiev 841a4b6913 synced with master 2023-04-25 12:40:59 +03:00
Gani Georgiev 0478f84867 updated /api/oauth2-redirect error messages 2023-04-25 11:52:56 +03:00
Gani Georgiev 0b5e189563 added OAuth2 redirect fallback message to notify the user to go back to the app 2023-04-25 08:36:02 +03:00
Gani Georgiev 7ce21cc4d9 Merge branch 'master' into develop 2023-04-24 16:04:45 +03:00
Gani Georgiev e61d48fe7b [#2349] fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source 2023-04-24 15:43:23 +03:00
Gani Georgiev 7acd90498c synced with master 2023-04-24 13:25:47 +03:00
Gani Georgiev cae3315e46 [#2349] fixed view query SELECT DISTINCT parsing 2023-04-24 12:54:57 +03:00
Gani Georgiev f2a011d63f [#2334] removed first char lowercase normalization to comply with the common backend regex 2023-04-21 19:03:59 +03:00
Gani Georgiev b31c2ceffa synced with master 2023-04-21 11:53:20 +03:00
Gani Georgiev a2a170ad82 updated go deps 2023-04-21 10:30:01 +03:00
Gani Georgiev 92dcee7250 skip Cache-Control header for the Admin UI root path 2023-04-21 10:28:28 +03:00
Gani Georgiev bd2521b14b added cache-control header for the admin ui assets 2023-04-21 01:19:57 +03:00
Gani Georgiev 0c63e0e219 added editor loading placeholder 2023-04-21 00:42:41 +03:00
Gani Georgiev d407ca2163 updated ui/dist 2023-04-20 23:59:59 +03:00
Gani Georgiev 5c57be8469 removed tinymce autoresize transition 2023-04-20 23:55:50 +03:00
Gani Georgiev d8f9b52011 updated go deps 2023-04-20 23:45:51 +03:00
Gani Georgiev faa0a9f7dc added admin console command tests 2023-04-20 23:39:48 +03:00
Gani Georgiev 4d94673839 Merge branch 'master' into develop 2023-04-20 21:18:49 +03:00
Gani Georgiev 4b0e614c68 updated changelog 2023-04-20 17:42:38 +03:00
Gani Georgiev 5c95a43042 move relations load to the end of the execution queue t 2023-04-20 17:38:04 +03:00
Gani Georgiev 025c9313e1 added admin console command 2023-04-20 17:20:56 +03:00
Gani Georgiev 0b023b2c02 Merge branch 'master' into develop 2023-04-20 16:24:46 +03:00
Gani Georgiev 8240addfab fixed rtl editor menu item typo 2023-04-20 15:36:42 +03:00
Gani Georgiev e1cfe7fc4a [#2327] enabled rtl for the editor field 2023-04-20 15:33:02 +03:00
Gani Georgiev 01f4765c09 added Command+S quick save alias and fixed relations draft restore 2023-04-20 14:38:24 +03:00
Gani Georgiev 818857dea2 [#2325] trigger the related record realtime events on custom record model change 2023-04-20 10:44:20 +03:00
Gani Georgiev 3358d8476b added apis.Serve helper 2023-04-20 05:06:22 +03:00
Gani Georgiev 060ed8013e synced with master 2023-04-19 14:09:06 +03:00
Gani Georgiev fdf4f6d3bd fixed ctrl+s not getting triggered in the editor field 2023-04-19 10:33:24 +03:00
Gani Georgiev d69e72deaf updated changelog 2023-04-18 07:09:07 +03:00
Gani Georgiev 0420a00c18 updated ui/dist 2023-04-18 06:32:01 +03:00
Gani Georgiev b39d607c8a updated changelog and package.json 2023-04-18 06:30:05 +03:00
Gani Georgiev abea28a1a9 fixed minor typos 2023-04-17 22:22:41 +03:00
Gani Georgiev a7d5a0640c allowed specifying non-context auth model for the file token endpoint 2023-04-17 22:05:09 +03:00
Gani Georgiev c937c06688 updated ui/dist 2023-04-17 20:59:04 +03:00
Gani Georgiev d11cec2226 updated cascade delete tooltip 2023-04-17 16:33:26 +03:00
Gani Georgiev c179b4c473 updated oauth2 docs and added create api rule tooltip 2023-04-17 16:29:56 +03:00
Gani Georgiev 00b04db5cf added ctrl+s record form shortcut and updated user external auths panel with the new provider logos 2023-04-17 14:35:49 +03:00
Gani Georgiev 1b8776926e updated protected files visualization 2023-04-17 13:28:41 +03:00
Gani Georgiev 5eb54c7a3d store unsaved record changes in local storage 2023-04-17 12:43:48 +03:00
Gani Georgiev 6127350e91 added eagerRequestDataCache middleware 2023-04-15 14:44:07 +03:00
Gani Georgiev 177230a765 renamed private to protected 2023-04-15 13:27:42 +03:00
Gani Georgiev 8cb0d980ad updated dart record view example and the relation cascade delete label 2023-04-15 11:27:07 +03:00
Gani Georgiev d44c2bb1a9 removed implicit autofocus as it is causing keyboard navigation issues 2023-04-15 11:23:59 +03:00
Gani Georgiev 3423433d01 updated ui/dist 2023-04-15 00:50:10 +03:00
Gani Georgiev 646b6a925b limit the number of displayed thumbs in the records listing 2023-04-15 00:47:49 +03:00
Gani Georgiev 5ddf9cd443 don't skip temp indexes migration if the indexes column is already created 2023-04-15 00:44:19 +03:00
Gani Georgiev 0351f3a1ad updated overlay panel autofocus 2023-04-14 15:28:24 +03:00
Gani Georgiev 53c735d00b added simple loose wildcard search term support in the Admin UI 2023-04-14 14:45:50 +03:00
Gani Georgiev 25769e971a added import.meta.env.BASE_URL prefix for the auth provider logos 2023-04-14 13:32:59 +03:00
Gani Georgiev aba6279feb Merge branch 'master' into develop 2023-04-14 12:58:03 +03:00
Gani Georgiev ac4a961a10 added unique error test for the record update api 2023-04-13 23:04:24 +03:00
Gani Georgiev 8317ae2e6b [#2287] fixed unique field detailed error not being returned on DrySubmit failure 2023-04-13 22:37:10 +03:00
Gani Georgiev b347c8d80c [#2246] added drop files support for the file field 2023-04-13 16:46:50 +03:00
Gani Georgiev af5f808144 updated OAuth2 providers ui 2023-04-13 15:38:12 +03:00
Gani Georgiev a77b62e5bd added extra table name checks in the overwritten dao hooks in case of duplicated ids 2023-04-12 16:46:08 +03:00
Gani Georgiev c77467a6a2 synced with master 2023-04-12 16:31:36 +03:00
Gani Georgiev 35e433f26b updated changelog and ui/dist 2023-04-12 16:23:53 +03:00
Gani Georgiev 59f23d3d23 [#2277] added check for nil dao hooks 2023-04-12 16:09:35 +03:00
Gani Georgiev 6a69a035a7 synced with master 2023-04-12 14:27:17 +03:00
Gani Georgiev b8373b23a4 updated changelog 2023-04-12 10:31:11 +03:00
Gani Georgiev 25c1db40ac [#2272] fixed panic on list.ExistInSliceWithRegex cache 2023-04-12 10:27:22 +03:00
Gani Georgiev 7b48e9d1c1 updated go deps 2023-04-10 23:00:36 +03:00
Gani Georgiev b537085bca updated test scenario name 2023-04-10 22:58:07 +03:00
Gani Georgiev 3e5b021dd8 fixed oauth2SubscriptionRedirect test 2023-04-10 22:51:59 +03:00
Gani Georgiev 151dbafc86 updated ui/dist 2023-04-10 22:33:13 +03:00
Gani Georgiev dc72d5adee [#55] added OAuth2 subscription redirect handler 2023-04-10 22:27:00 +03:00
Gani Georgiev c826514eca synced with master 2023-04-07 11:39:45 +03:00
Gani Georgiev b7407edcae updated go.mod 2023-04-06 20:22:50 +03:00
Gani Georgiev 1420d717e3 [#2231] revert the aws-sdk-go-v2 change 2023-04-06 20:17:22 +03:00
Gani Georgiev f5f8c35a17 [#2221] fixed Admin UI Logs meta field visualization in Firefox 2023-04-05 19:43:41 +03:00
Gani Georgiev 733d7dacdb [#215] updated the admin ui to allow displaying private files 2023-04-05 13:23:22 +03:00
Gani Georgiev cd45854792 [#215] added Admin UI Private file option toggle 2023-04-04 21:38:03 +03:00
Gani Georgiev ba7000125b [#215] enabled Settings.AdminFileToken validations and added more tests 2023-04-04 20:47:03 +03:00
Gani Georgiev 64c3e3b3c5 [#215] added server-side handlers for serving private files 2023-04-04 20:33:35 +03:00
Gani Georgiev 9f76ad234c [#2205] fixed Record.WithUnknownData() typo 2023-04-03 22:01:59 +03:00
551 changed files with 46297 additions and 47352 deletions
+8 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.20.0'
go-version: '>=1.21.3'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
@@ -29,6 +29,13 @@ jobs:
- name: Build Admin dashboard UI
run: npm --prefix=./ui ci && npm --prefix=./ui run build
# Temporary disable as the types can have random generated identifiers making it non-deterministic.
#
# # Similar to the above, the jsvm types are pregenerated locally
# # but its here to ensure that it wasn't forgotten to be executed.
# - name: Generate jsvm types
# run: go run ./plugins/jsvm/internal/types/types.go
# The prebuilt golangci-lint doesn't support go 1.18+ yet
# https://github.com/golangci/golangci-lint/issues/2649
# - name: Run linter
+1 -8
View File
@@ -7,6 +7,7 @@ before:
- go mod tidy
builds:
# used only for tests
- id: build_cgo
main: ./examples/base
binary: pocketbase
@@ -46,14 +47,6 @@ release:
draft: true
archives:
- id: archive_cgo
builds: [build_cgo]
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}_cgo'
format: zip
files:
- LICENSE*
- CHANGELOG*
- id: archive_noncgo
builds: [build_noncgo]
format: zip
+697 -18
View File
@@ -1,6 +1,685 @@
## v0.19.3
- Added the release notes to the console output of `./pocketbase update` ([#3685](https://github.com/pocketbase/pocketbase/discussions/3685)).
- Added missing documention for the JSVM `$mails.*` bindings.
- Relaxed the OAuth2 redirect url validation to allow any string value ([#3689](https://github.com/pocketbase/pocketbase/pull/3689); thanks @sergeypdev).
_Note that the redirect url format is still bound to the accepted values by the specific OAuth2 provider._
## v0.19.2
- Updated the JSVM generated types ([#3627](https://github.com/pocketbase/pocketbase/issues/3627), [#3662](https://github.com/pocketbase/pocketbase/issues/3662)).
## v0.19.1
- Fixed `tokenizer.Scan()/ScanAll()` to ignore the separators from the default trim cutset.
An option to return also the empty found tokens was also added via `Tokenizer.KeepEmptyTokens(true)`.
_This should fix the parsing of whitespace charactes around view query column names when no quotes are used ([#3616](https://github.com/pocketbase/pocketbase/discussions/3616#discussioncomment-7398564))._
- Fixed the `:excerpt(max, withEllipsis?)` `field` query param modifier to properly add space to the generated text fragment after block tags.
## v0.19.0
- Added Patreon OAuth2 provider ([#3323](https://github.com/pocketbase/pocketbase/pull/3323); thanks @ghostdevv).
- Added mailcow OAuth2 provider ([#3364](https://github.com/pocketbase/pocketbase/pull/3364); thanks @thisni1s).
- Added support for `:excerpt(max, withEllipsis?)` `fields` modifier that will return a short plain text version of any string value (html tags are stripped).
This could be used to minimize the downloaded json data when listing records with large `editor` html values.
```js
await pb.collection("example").getList(1, 20, {
"fields": "*,description:excerpt(100)"
})
```
- Several Admin UI improvements:
- Count the total records separately to speed up the query execution for large datasets ([#3344](https://github.com/pocketbase/pocketbase/issues/3344)).
- Enclosed the listing scrolling area within the table so that the horizontal scrollbar and table header are always reachable ([#2505](https://github.com/pocketbase/pocketbase/issues/2505)).
- Allowed opening the record preview/update form via direct URL ([#2682](https://github.com/pocketbase/pocketbase/discussions/2682)).
- Reintroduced the local `date` field tooltip on hover.
- Speed up the listing loading times for records with large `editor` field values by initially fetching only a partial of the records data (the complete record data is loaded on record preview/update).
- Added "Media library" (collection images picker) support for the TinyMCE `editor` field.
- Added support to "pin" collections in the sidebar.
- Added support to manually resize the collections sidebar.
- More clear "Nonempty" field label style.
- Removed the legacy `.woff` and `.ttf` fonts and keep only `.woff2`.
- Removed the explicit `Content-Type` charset from the realtime response due to compatibility issues with IIS ([#3461](https://github.com/pocketbase/pocketbase/issues/3461)).
_The `Connection:keep-alive` realtime response header was also removed as it is not really used with HTTP2 anyway._
- Added new JSVM bindings:
- `new Cookie({ ... })` constructor for creating `*http.Cookie` equivalent value.
- `new SubscriptionMessage({ ... })` constructor for creating a custom realtime subscription payload.
- Soft-deprecated `$os.exec()` in favour of `$os.cmd()` to make it more clear that the call only prepares the command and doesn't execute it.
- ⚠️ Bumped the min required Go version to 1.19.
## v0.18.10
- Added global `raw` template function to allow outputting raw/verbatim HTML content in the JSVM templates ([#3476](https://github.com/pocketbase/pocketbase/discussions/3476)).
```
{{.description|raw}}
```
- Trimmed view query semicolon and allowed single quotes for column aliases ([#3450](https://github.com/pocketbase/pocketbase/issues/3450#issuecomment-1748044641)).
_Single quotes are usually [not a valid identifier quote characters](https://www.sqlite.org/lang_keywords.html), but for resilience and compatibility reasons SQLite allows them in some contexts where only an identifier is expected._
- Bumped the GitHub action to use [min Go 1.21.2](https://github.com/golang/go/issues?q=milestone%3AGo1.21.2) (_the fixed issues are not critical as they are mostly related to the compiler/build tools_).
## v0.18.9
- Fixed empty thumbs directories not getting deleted on Windows after deleting a record img file ([#3382](https://github.com/pocketbase/pocketbase/issues/3382)).
- Updated the generated JSVM typings to silent the TS warnings when trying to access a field/method in a Go->TS interface.
## v0.18.8
- Minor fix for the View collections API Preview and Admin UI listings incorrectly showing the `created` and `updated` fields as `N/A` when the view query doesn't have them.
## v0.18.7
- Fixed JS error in the Admin UI when listing records with invalid `relation` field value ([#3372](https://github.com/pocketbase/pocketbase/issues/3372)).
_This could happen usually only during custom SQL import scripts or when directly modifying the record field value without data validations._
- Updated Go deps and the generated JSVM types.
## v0.18.6
- Return the response headers and cookies in the `$http.send()` result ([#3310](https://github.com/pocketbase/pocketbase/discussions/3310)).
- Added more descriptive internal error message for missing user/admin email on password reset requests.
- Updated Go deps.
## v0.18.5
- Fixed minor Admin UI JS error in the auth collection options panel introduced with the change from v0.18.4.
## v0.18.4
- Added escape character (`\`) support in the Admin UI to allow using `select` field values with comma ([#2197](https://github.com/pocketbase/pocketbase/discussions/2197)).
## v0.18.3
- Exposed a global JSVM `readerToString(reader)` helper function to allow reading Go `io.Reader` values ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)).
- Bumped the GitHub action to use [min Go 1.21.1](https://github.com/golang/go/issues?q=milestone%3AGo1.21.1+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `html/template` and `net/http` security fixes.
## v0.18.2
- Prevent breaking the record form in the Admin UI in case the browser's localStorage quota has been exceeded when uploading or storing large `editor` values ([#3265](https://github.com/pocketbase/pocketbase/issues/3265)).
- Updated docs and missing JSVM typings.
- Exposed additional crypto primitives under the `$security.*` JSVM namespace ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)):
```js
// HMAC with SHA256
$security.hs256("hello", "secret")
// HMAC with SHA512
$security.hs512("hello", "secret")
// compare 2 strings with a constant time
$security.equal(hash1, hash2)
```
## v0.18.1
- Excluded the local temp dir from the backups ([#3261](https://github.com/pocketbase/pocketbase/issues/3261)).
## v0.18.0
- Simplified the `serve` command to accept domain name(s) as argument to reduce any additional manual hosts setup that sometimes previously was needed when deploying on production ([#3190](https://github.com/pocketbase/pocketbase/discussions/3190)).
```sh
./pocketbase serve yourdomain.com
```
- Added `fields` wildcard (`*`) support.
- Added option to upload a backup file from the Admin UI ([#2599](https://github.com/pocketbase/pocketbase/issues/2599)).
- Registered a custom Deflate compressor to speedup (_nearly 2-3x_) the backups generation for the sake of a small zip size increase.
_Based on several local tests, `pb_data` of ~500MB (from which ~350MB+ are several hundred small files) results in a ~280MB zip generated for ~11s (previously it resulted in ~250MB zip but for ~35s)._
- Added the application name as part of the autogenerated backup name for easier identification ([#3066](https://github.com/pocketbase/pocketbase/issues/3066)).
- Added new `SmtpConfig.LocalName` option to specify a custom domain name (or IP address) for the initial EHLO/HELO exchange ([#3097](https://github.com/pocketbase/pocketbase/discussions/3097)).
_This is usually required for verification purposes only by some SMTP providers, such as on-premise [Gmail SMTP-relay](https://support.google.com/a/answer/2956491)._
- Added `NoDecimal` `number` field option.
- `editor` field improvements:
- Added new "Strip urls domain" option to allow controlling the default TinyMCE urls behavior (_default to `false` for new content_).
- Normalized pasted text while still preserving links, lists, tables, etc. formatting ([#3257](https://github.com/pocketbase/pocketbase/issues/3257)).
- Added option to auto generate admin and auth record passwords from the Admin UI.
- Added JSON validation and syntax highlight for the `json` field in the Admin UI ([#3191](https://github.com/pocketbase/pocketbase/issues/3191)).
- Added datetime filter macros:
```
// all macros are UTC based
@second - @now second number (0-59)
@minute - @now minute number (0-59)
@hour - @now hour number (0-23)
@weekday - @now weekday number (0-6)
@day - @now day number
@month - @now month number
@year - @now year number
@todayStart - beginning of the current day as datetime string
@todayEnd - end of the current day as datetime string
@monthStart - beginning of the current month as datetime string
@monthEnd - end of the current month as datetime string
@yearStart - beginning of the current year as datetime string
@yearEnd - end of the current year as datetime string
```
- Added cron expression macros ([#3132](https://github.com/pocketbase/pocketbase/issues/3132)):
```
@yearly - "0 0 1 1 *"
@annually - "0 0 1 1 *"
@monthly - "0 0 1 * *"
@weekly - "0 0 * * 0"
@daily - "0 0 * * *"
@midnight - "0 0 * * *"
@hourly - "0 * * * *"
```
- ⚠️ Added offset argument `Dao.FindRecordsByFilter(collection, filter, sort, limit, offset, [params...])`.
_If you don't need an offset, you can set it to `0`._
- To minimize the footguns with `Dao.FindFirstRecordByFilter()` and `Dao.FindRecordsByFilter()`, the functions now supports an optional placeholder params argument that is safe to be populated with untrusted user input.
The placeholders are in the same format as when binding regular SQL parameters.
```go
// unsanitized and untrusted filter variables
status := "..."
author := "..."
app.Dao().FindFirstRecordByFilter("articles", "status={:status} && author={:author}", dbx.Params{
"status": status,
"author": author,
})
app.Dao().FindRecordsByFilter("articles", "status={:status} && author={:author}", "-created", 10, 0, dbx.Params{
"status": status,
"author": author,
})
```
- Added JSVM `$mails.*` binds for the corresponding Go [mails package](https://pkg.go.dev/github.com/pocketbase/pocketbase/mails) functions.
- Added JSVM helper crypto primitives under the `$security.*` namespace:
```js
$security.md5(text)
$security.sha256(text)
$security.sha512(text)
```
- ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections.
- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
- ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)).
- ⚠️ Changes to `tests.ApiScenario` struct:
- The `ApiScenario.AfterTestFunc` now receive as 3rd argument `*http.Response` pointer instead of `*echo.Echo` as the latter is not really useful in this context.
```go
// old
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo)
// new
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response)
```
- The `ApiScenario.TestAppFactory` now accept the test instance as argument and no longer expect an error as return result ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025#discussioncomment-6592272)).
```go
// old
TestAppFactory: func() (*tests.TestApp, error)
// new
TestAppFactory: func(t *testing.T) *tests.TestApp
```
_Returning a `nil` app instance from the factory results in test failure. You can enforce a custom test failure by calling `t.Fatal(err)` inside the factory._
- Bumped the min required TLS version to 1.2 in order to improve the cert reputation score.
- Reduced the default JSVM prewarmed pool size to 25 to reduce the initial memory consumptions (_you can manually adjust the pool size with `--hooksPool=50` if you need to, but the default should suffice for most cases_).
- Update `gocloud.dev` dependency to v0.34 and explicitly set the new `NoTempDir` fileblob option to prevent the cross-device link error introduced with v0.33.
- Other minor Admin UI and docs improvements.
## v0.17.7
- Fixed the autogenerated `down` migrations to properly revert the old collection rules in case a change was made in `up` ([#3192](https://github.com/pocketbase/pocketbase/pull/3192); thanks @impact-merlinmarek).
_Existing `down` migrations can't be fixed but that should be ok as usually the `down` migrations are rarely used against prod environments since they can cause data loss and, while not ideal, the previous old behavior of always setting the rules to `null/nil` is safer than not updating the rules at all._
- Updated some Go deps.
## v0.17.6
- Fixed JSVM `require()` file path error when using Windows-style path delimiters ([#3163](https://github.com/pocketbase/pocketbase/issues/3163#issuecomment-1685034438)).
## v0.17.5
- Added quotes around the wrapped view query columns introduced with v0.17.4.
## v0.17.4
- Fixed Views record retrieval when numeric id is used ([#3110](https://github.com/pocketbase/pocketbase/issues/3110)).
_With this fix we also now properly recognize `CAST(... as TEXT)` and `CAST(... as BOOLEAN)` as `text` and `bool` fields._
- Fixed `relation` "Cascade delete" tooltip message ([#3098](https://github.com/pocketbase/pocketbase/issues/3098)).
- Fixed jsvm error message prefix on failed migrations ([#3103](https://github.com/pocketbase/pocketbase/pull/3103); thanks @nzhenev).
- Disabled the initial Admin UI admins counter cache when there are no initial admins to allow detecting externally created accounts (eg. with the `admin` command) ([#3106](https://github.com/pocketbase/pocketbase/issues/3106)).
- Downgraded `google/go-cloud` dependency to v0.32.0 until v0.34.0 is released to prevent the `os.TempDir` `cross-device link` errors as too many users complained about it.
## v0.17.3
- Fixed Docker `cross-device link` error when creating `pb_data` backups on a local mounted volume ([#3089](https://github.com/pocketbase/pocketbase/issues/3089)).
- Fixed the error messages for relation to views ([#3090](https://github.com/pocketbase/pocketbase/issues/3090)).
- Always reserve space for the scrollbar to reduce the layout shifts in the Admin UI records listing due to the deprecated `overflow: overlay`.
- Enabled lazy loading for the Admin UI thumb images.
## v0.17.2
- Soft-deprecated `$http.send({ data: object, ... })` in favour of `$http.send({ body: rawString, ... })`
to allow sending non-JSON body with the request ([#3058](https://github.com/pocketbase/pocketbase/discussions/3058)).
The existing `data` prop will still work, but it is recommended to use `body` instead (_to send JSON you can use `JSON.stringify(...)` as body value_).
- Added `core.RealtimeConnectEvent.IdleTimeout` field to allow specifying a different realtime idle timeout duration per client basis ([#3054](https://github.com/pocketbase/pocketbase/discussions/3054)).
- Fixed `apis.RequestData` deprecation log note ([#3068](https://github.com/pocketbase/pocketbase/pull/3068); thanks @gungjodi).
## v0.17.1
- Use relative path when redirecting to the OAuth2 providers page in the Admin UI to support subpath deployments ([#3026](https://github.com/pocketbase/pocketbase/pull/3026); thanks @sonyarianto).
- Manually trigger the `OnBeforeServe` hook for `tests.ApiScenario` ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025)).
- Trigger the JSVM `cronAdd()` handler only on app `serve` to prevent unexpected (and eventually duplicated) cron handler calls when custom console commands are used ([#3024](https://github.com/pocketbase/pocketbase/discussions/3024#discussioncomment-6592703)).
- The `console.log()` messages are now written to the `stdout` instead of `stderr`.
## v0.17.0
- New more detailed guides for using PocketBase as framework (both Go and JS).
_If you find any typos or issues with the docs please report them in https://github.com/pocketbase/site._
- Added new experimental JavaScript app hooks binding via [goja](https://github.com/dop251/goja).
They are available by default with the prebuilt executable if you create `*.pb.js` file(s) in the `pb_hooks` directory.
Lower your expectations because the integration comes with some limitations. For more details please check the [Extend with JavaScript](https://pocketbase.io/docs/js-overview/) guide.
Optionally, you can also enable the JS app hooks as part of a custom Go build for dynamic scripting but you need to register the `jsvm` plugin manually:
```go
jsvm.MustRegister(app core.App, config jsvm.Config{})
```
- Added Instagram OAuth2 provider ([#2534](https://github.com/pocketbase/pocketbase/pull/2534); thanks @pnmcosta).
- Added VK OAuth2 provider ([#2533](https://github.com/pocketbase/pocketbase/pull/2533); thanks @imperatrona).
- Added Yandex OAuth2 provider ([#2762](https://github.com/pocketbase/pocketbase/pull/2762); thanks @imperatrona).
- Added new fields to `core.ServeEvent`:
```go
type ServeEvent struct {
App App
Router *echo.Echo
// new fields
Server *http.Server // allows adjusting the HTTP server config (global timeouts, TLS options, etc.)
CertManager *autocert.Manager // allows adjusting the autocert options (cache dir, host policy, etc.)
}
```
- Added `record.ExpandedOne(rel)` and `record.ExpandedAll(rel)` helpers to retrieve casted single or multiple expand relations from the already loaded "expand" Record data.
- Added rule and filter record `Dao` helpers:
```go
app.Dao().FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
app.Dao().FindFirstRecordByFilter("posts", "slug='test' && active=true")
app.Dao().CanAccessRecord(record, requestInfo, rule)
```
- Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks.
- Use a default fetch function that will return all relations in case the `fetchFunc` argument of `Dao.ExpandRecord(record, expands, fetchFunc)` and `Dao.ExpandRecords(records, expands, fetchFunc)` is `nil`.
- For convenience it is now possible to call `Dao.RecordQuery(collectionModelOrIdentifier)` with just the collection id or name.
In case an invalid collection id/name string is passed the query will be resolved with cancelled context error.
- Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`.
- Added support for wrapped API errors (_in case Go 1.20+ is used with multiple wrapped errors, the first `apis.ApiError` takes precedence_).
- Added `?download=1` file query parameter to the file serving endpoint to force the browser to always download the file and not show its preview.
- Added new utility `github.com/pocketbase/pocketbase/tools/template` subpackage to assist with rendering HTML templates using the standard Go `html/template` and `text/template` syntax.
- Added `types.JsonMap.Get(k)` and `types.JsonMap.Set(k, v)` helpers for the cases where the type aliased direct map access is not allowed (eg. in [goja](https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods)).
- Soft-deprecated `security.NewToken()` in favor of `security.NewJWT()`.
- `Hook.Add()` and `Hook.PreAdd` now returns a unique string identifier that could be used to remove the registered hook handler via `Hook.Remove(handlerId)`.
- Changed the after* hooks to be called right before writing the user response, allowing users to return response errors from the after hooks.
There is also no longer need for returning explicitly `hook.StopPropagtion` when writing custom response body in a hook because we will skip the finalizer response body write if a response was already "committed".
- ⚠️ Renamed `*Options{}` to `Config{}` for consistency and replaced the unnecessary pointers with their value equivalent to keep the applied configuration defaults isolated within their function calls:
```go
old: pocketbase.NewWithConfig(config *pocketbase.Config) *pocketbase.PocketBase
new: pocketbase.NewWithConfig(config pocketbase.Config) *pocketbase.PocketBase
old: core.NewBaseApp(config *core.BaseAppConfig) *core.BaseApp
new: core.NewBaseApp(config core.BaseAppConfig) *core.BaseApp
old: apis.Serve(app core.App, options *apis.ServeOptions) error
new: apis.Serve(app core.App, config apis.ServeConfig) (*http.Server, error)
old: jsvm.MustRegisterMigrations(app core.App, options *jsvm.MigrationsOptions)
new: jsvm.MustRegister(app core.App, config jsvm.Config)
old: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, options *ghupdate.Options)
new: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, config ghupdate.Config)
old: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, options *migratecmd.Options)
new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config)
```
- ⚠️ Changed the type of `subscriptions.Message.Data` from `string` to `[]byte` because `Data` usually is a json bytes slice anyway.
- ⚠️ Renamed `models.RequestData` to `models.RequestInfo` and soft-deprecated `apis.RequestData(c)` in favor of `apis.RequestInfo(c)` to avoid the stuttering with the `Data` field.
_The old `apis.RequestData()` method still works to minimize the breaking changes but it is recommended to replace it with `apis.RequestInfo(c)`._
- ⚠️ Changes to the List/Search APIs
- Added new query parameter `?skipTotal=1` to skip the `COUNT` query performed with the list/search actions ([#2965](https://github.com/pocketbase/pocketbase/discussions/2965)).
If `?skipTotal=1` is set, the response fields `totalItems` and `totalPages` will have `-1` value (this is to avoid having different JSON responses and to differentiate from the zero default).
With the latest JS SDK 0.16+ and Dart SDK v0.11+ versions `skipTotal=1` is set by default for the `getFirstListItem()` and `getFullList()` requests.
- The count and regular select statements also now executes concurrently, meaning that we no longer perform normalization over the `page` parameter and in case the user
request a page that doesn't exist (eg. `?page=99999999`) we'll return empty `items` array.
- Reverted the default `COUNT` column to `id` as there are some common situations where it can negatively impact the query performance.
Additionally, from this version we also set `PRAGMA temp_store = MEMORY` so that also helps with the temp B-TREE creation when `id` is used.
_There are still scenarios where `COUNT` queries with `rowid` executes faster, but the majority of the time when nested relations lookups are used it seems to have the opposite effect (at least based on the benchmarks dataset)._
- ⚠️ Disallowed relations to views **from non-view** collections ([#3000](https://github.com/pocketbase/pocketbase/issues/3000)).
The change was necessary because I wasn't able to find an efficient way to track view changes and the previous behavior could have too many unexpected side-effects (eg. view with computed ids).
There is a system migration that will convert the existing view `relation` fields to `json` (multiple) and `text` (single) fields.
This could be a breaking change if you have `relation` to view and use `expand` or some of the `relation` view fields as part of a collection rule.
- ⚠️ Added an extra `action` argument to the `Dao` hooks to allow skipping the default persist behavior.
In preparation for the logs generalization, the `Dao.After*Func` methods now also allow returning an error.
- Allowed `0` as `RelationOptions.MinSelect` value to avoid the ambiguity between 0 and non-filled input value ([#2817](https://github.com/pocketbase/pocketbase/discussions/2817)).
- Fixed zero-default value not being used if the field is not explicitly set when manually creating records ([#2992](https://github.com/pocketbase/pocketbase/issues/2992)).
Additionally, `record.Get(field)` will now always return normalized value (the same as in the json serialization) for consistency and to avoid ambiguities with what is stored in the related DB table.
The schema fields columns `DEFAULT` definition was also updated for new collections to ensure that `NULL` values can't be accidentally inserted.
- Fixed `migrate down` not returning the correct `lastAppliedMigrations()` when the stored migration applied time is in seconds.
- Fixed realtime delete event to be called after the record was deleted from the DB (_including transactions and cascade delete operations_).
- Other minor fixes and improvements (typos and grammar fixes, updated dependencies, removed unnecessary 404 error check in the Admin UI, etc.).
## v0.16.10
- Added multiple valued fields (`relation`, `select`, `file`) normalizations to ensure that the zero-default value of a newly created multiple field is applied for already existing data ([#2930](https://github.com/pocketbase/pocketbase/issues/2930)).
## v0.16.9
- Register the `eagerRequestInfoCache` middleware only for the internal `api` group routes to avoid conflicts with custom route handlers ([#2914](https://github.com/pocketbase/pocketbase/issues/2914)).
## v0.16.8
- Fixed unique validator detailed error message not being returned when camelCase field name is used ([#2868](https://github.com/pocketbase/pocketbase/issues/2868)).
- Updated the index parser to allow no space between the table name and the columns list ([#2864](https://github.com/pocketbase/pocketbase/discussions/2864#discussioncomment-6373736)).
- Updated go deps.
## v0.16.7
- Minor optimization for the list/search queries to use `rowid` with the `COUNT` statement when available.
_This eliminates the temp B-TREE step when executing the query and for large datasets (eg. 150k) it could have 10x improvement (from ~580ms to ~60ms)._
## v0.16.6
- Fixed collection index column sort normalization in the Admin UI ([#2681](https://github.com/pocketbase/pocketbase/pull/2681); thanks @SimonLoir).
- Removed unnecessary admins count in `apis.RequireAdminAuthOnlyIfAny()` middleware ([#2726](https://github.com/pocketbase/pocketbase/pull/2726); thanks @svekko).
- Fixed `multipart/form-data` request bind not populating map array values ([#2763](https://github.com/pocketbase/pocketbase/discussions/2763#discussioncomment-6278902)).
- Upgraded npm and Go dependencies.
## v0.16.5
- Fixed the Admin UI serialization of implicit relation display fields ([#2675](https://github.com/pocketbase/pocketbase/issues/2675)).
- Reset the Admin UI sort in case the active sort collection field is renamed or deleted.
## v0.16.4
- Fixed the selfupdate command not working on Windows due to missing `.exe` in the extracted binary path ([#2589](https://github.com/pocketbase/pocketbase/discussions/2589)).
_Note that the command on Windows will work from v0.16.4+ onwards, meaning that you still will have to update manually one more time to v0.16.4._
- Added `int64`, `int32`, `uint`, `uint64` and `uint32` support when scanning `types.DateTime` ([#2602](https://github.com/pocketbase/pocketbase/discussions/2602))
- Updated dependencies.
## v0.16.3
- Fixed schema fields sort not working on Safari/Gnome Web ([#2567](https://github.com/pocketbase/pocketbase/issues/2567)).
- Fixed default `PRAGMA`s not being applied for new connections ([#2570](https://github.com/pocketbase/pocketbase/discussions/2570)).
## v0.16.2
- Fixed backups archive not excluding the local `backups` directory on Windows ([#2548](https://github.com/pocketbase/pocketbase/discussions/2548#discussioncomment-5979712)).
- Changed file field to not use `dataTransfer.effectAllowed` when dropping files since it is not reliable and consistent across different OS and browsers ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
- Auto register the initial generated snapshot migration to prevent incorrectly reapplying the snapshot on Docker restart ([#2551](https://github.com/pocketbase/pocketbase/discussions/2551)).
- Fixed missing view id field error message typo.
## v0.16.1
- Fixed backup restore not working in a container environment when `pb_data` is mounted as volume ([#2519](https://github.com/pocketbase/pocketbase/issues/2519)).
- Fixed Dart SDK realtime API preview example ([#2523](https://github.com/pocketbase/pocketbase/pull/2523); thanks @xFrann).
- Fixed typo in the backups create panel ([#2526](https://github.com/pocketbase/pocketbase/pull/2526); thanks @dschissler).
- Removed unnecessary slice length check in `list.ExistInSlice` ([#2527](https://github.com/pocketbase/pocketbase/pull/2527); thanks @KunalSin9h).
- Avoid mutating the cached request data on OAuth2 user create ([#2535](https://github.com/pocketbase/pocketbase/discussions/2535)).
- Fixed Export Collections "Download as JSON" ([#2540](https://github.com/pocketbase/pocketbase/issues/2540)).
- Fixed file field drag and drop not working in Firefox and Safari ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
## v0.16.0
- Added automated backups (_+ cron rotation_) APIs and UI for the `pb_data` directory.
The backups can be also initialized programmatically using `app.CreateBackup("backup.zip")`.
There is also experimental restore method - `app.RestoreBackup("backup.zip")` (_currently works only on UNIX systems as it relies on execve_).
The backups can be stored locally or in external S3 storage (_it has its own configuration, separate from the file uploads storage filesystem_).
- Added option to limit the returned API fields using the `?fields` query parameter.
The "fields picker" is applied for `SearchResult.Items` and every other JSON response. For example:
```js
// original: {"id": "RECORD_ID", "name": "abc", "description": "...something very big...", "items": ["id1", "id2"], "expand": {"items": [{"id": "id1", "name": "test1"}, {"id": "id2", "name": "test2"}]}}
// output: {"name": "abc", "expand": {"items": [{"name": "test1"}, {"name": "test2"}]}}
const result = await pb.collection("example").getOne("RECORD_ID", {
expand: "items",
fields: "name,expand.items.name",
})
```
- Added new `./pocketbase update` command to selfupdate the prebuilt executable (with option to generate a backup of your `pb_data`).
- Added new `./pocketbase admin` console command:
```sh
// creates new admin account
./pocketbase admin create test@example.com 123456890
// changes the password of an existing admin account
./pocketbase admin update test@example.com 0987654321
// deletes single admin account (if exists)
./pocketbase admin delete test@example.com
```
- Added `apis.Serve(app, options)` helper to allow starting the API server programmatically.
- Updated the schema fields Admin UI for "tidier" fields visualization.
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
- Added new `tools/cron` helper subpackage for scheduling task using cron-like syntax (_this eventually may get exported in the future in a separate repo_).
- Added new `Filesystem.List(prefix)` helper to retrieve a flat list with all files under the provided prefix.
- Added new `App.NewBackupsFilesystem()` helper to create a dedicated filesystem abstraction for managing app data backups.
- Added new `App.OnTerminate()` hook (_executed right before app termination, eg. on `SIGTERM` signal_).
- Added `accept` file field attribute with the field MIME types ([#2466](https://github.com/pocketbase/pocketbase/pull/2466); thanks @Nikhil1920).
- Added support for multiple files sort in the Admin UI ([#2445](https://github.com/pocketbase/pocketbase/issues/2445)).
- Added support for multiple relations sort in the Admin UI.
- Added `meta.isNew` to the OAuth2 auth JSON response to indicate a newly OAuth2 created PocketBase user.
## v0.15.3
- Updated the Admin UI to use the latest JS SDK to resolve the `isNew` record field conflict ([#2385](https://github.com/pocketbase/pocketbase/discussions/2385)).
- Fixed `editor` field fullscreen `z-index` ([#2410](https://github.com/pocketbase/pocketbase/issues/2410)).
- Inserts the default app settings as part of the system init migration so that they are always available when accessed from within a user defined migration ([#2423](https://github.com/pocketbase/pocketbase/discussions/2423)).
## v0.15.2
- Fixed View query `SELECT DISTINCT` identifiers parsing ([#2349-5706019](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5706019)).
- Fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source ([#2349-5707675](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5707675)).
- Added OAuth2 redirect fallback message to notify the user to go back to the app in case the browser window is not auto closed.
## v0.15.1
- Trigger the related `Record` model realtime subscription events on [custom model struct](https://pocketbase.io/docs/custom-models/) save ([#2325](https://github.com/pocketbase/pocketbase/discussions/2325)).
- Fixed `Ctrl + S` in the `editor` field not propagating the quick save shortcut to the parent form.
- Added `⌘ + S` alias for the record quick save shortcut (_I have no Mac device to test it but it should work based on [`e.metaKey` docs](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey)_).
- Enabled RTL for the TinyMCE editor ([#2327](https://github.com/pocketbase/pocketbase/issues/2327)).
- Reduced the record form vertical layout shifts and slightly improved the rendering speed when loading multiple `relation` fields.
- Enabled Admin UI assets cache.
## v0.15.0
- Simplified the OAuth2 authentication flow in a single "all in one" call ([#55](https://github.com/pocketbase/pocketbase/issues/55)).
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
The manual code-token exchange flow is still supported but the SDK method is renamed to `authWithOAuth2Code()` (_to minimize the breaking changes the JS SDK has a function overload that will proxy the existing `authWithOauth2` calls to `authWithOAuth2Code`_).
For more details and example, you could check https://pocketbase.io/docs/authentication/#oauth2-integration.
- Added support for protected files ([#215](https://github.com/pocketbase/pocketbase/issues/215)).
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
It works with a short lived (~5min) file token passed as query param with the file url.
For more details and example, you could check https://pocketbase.io/docs/files-handling/#protected-files.
- ⚠️ Fixed typo in `Record.WithUnkownData()` -> `Record.WithUnknownData()`.
- Added simple loose wildcard search term support in the Admin UI.
- Added auto "draft" to allow restoring previous record state in case of accidental reload or power outage.
- Added `Ctrl + S` shortcut to save the record changes without closing the panel.
- Added "drop files" support for the file upload field.
- Refreshed the OAuth2 Admin UI.
## v0.14.5
- Added checks for `nil` hooks in `forms.RecordUpsert` when used with custom `Dao` ([#2277](https://github.com/pocketbase/pocketbase/issues/2277)).
- Fixed unique detailed field error not returned on record create failure ([#2287](https://github.com/pocketbase/pocketbase/discussions/2287)).
## v0.14.4
- Fixed concurrent map write pannic on `list.ExistInSliceWithRegex()` cache ([#2272](https://github.com/pocketbase/pocketbase/issues/2272)).
## v0.14.3
- Fixed Admin UI Logs `meta` visualization in Firefox ([#2221](https://github.com/pocketbase/pocketbase/issues/2221)).
- Downgraded to v1 of the `aws/aws-sdk-go` package since v2 has compatibility issues with GCS ([#2231](https://github.com/pocketbase/pocketbase/issues/2231)).
- Upgraded the GitHub action to use [min Go 1.20.3](https://github.com/golang/go/issues?q=milestone%3AGo1.20.3+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `net/http` security fixes.
## v0.14.2
- Reverted part of the old `COALESCE` handling to continue supporting empty string comparison with missing joined relation fields.
- Reverted part of the old `COALESCE` handling as a fallback to support empty string comparison with missing joined relation fields.
## v0.14.1
@@ -55,16 +734,16 @@
- Added CGO linux target for the prebuilt executable.
- **!** Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
- ⚠️ Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
- **!** Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
- ⚠️ Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
- **!** Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
- ⚠️ Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`.
- **!** Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
- ⚠️ Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
- **!** Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
- ⚠️ Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
- Other minor UI improvements.
@@ -121,11 +800,11 @@
- Added `UploadedFiles` field to the `RecordCreateEvent` and `RecordUpdateEvent` event structs.
- **!** Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
- ⚠️ Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
- **!** Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
- ⚠️ Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
- **!** Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
- ⚠️ Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
If you are sending custom emails, you'll have to replace:
```go
@@ -145,11 +824,11 @@
}
```
- **!** Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
- ⚠️ Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
_If you've previously used Authentik, make sure to rename the provider key in your code to `oidc`._
_To enable more than one OIDC provider you can use the additional `oidc2` and `oidc3` provider keys._
- **!** Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
- ⚠️ Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
- Updated the internal redirects to allow easier subpath deployment when behind a reverse proxy.
@@ -254,7 +933,7 @@
```
For all those event hooks `*hook.Hook` was replaced with `*hooks.TaggedHook`, but the hook methods signatures are the same so it should behave as it was previously if no tags were specified.
- **!** Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
- ⚠️ Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
In order to support seamlessly both `application/json` and `multipart/form-data`
requests, the following normalization rules are applied if the `json` field is a
@@ -328,7 +1007,7 @@
_Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
- ! Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
- ⚠️ Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
If you want a "at least one of" type of condition, you can prefix the operator with `?`.
```js
// for each someRelA.someRelB record require the "status" field to be "active"
@@ -408,7 +1087,7 @@
## v0.10.3
- ! Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
- ⚠️ Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
@@ -444,7 +1123,7 @@
- Refactored the `core.app.Bootstrap()` to be called before starting the cobra commands ([#1267](https://github.com/pocketbase/pocketbase/discussions/1267)).
- ! Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
- ⚠️ Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
```go
DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns
DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns
@@ -454,9 +1133,9 @@
- Added new helper method `core.App.IsBootstrapped()` to check the current app bootstrap state.
- ! Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
- ⚠️ Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
- ! Removed `rest.UploadedFile` struct (see below `filesystem.File`).
- ⚠️ Removed `rest.UploadedFile` struct (see below `filesystem.File`).
- Added generic file resource struct that allows loading and uploading file content from
different sources (at the moment multipart/form-data requests and from the local filesystem).
@@ -1150,7 +1829,7 @@ Please check the individual SDK package changelog and apply the necessary change
- Added option to return serialized custom `models.Record` fields data:
```go
func (m *Record) UnknownData() map[string]any
func (m *Record) WithUnkownData(state bool)
func (m *Record) WithUnknownData(state bool)
```
- Deleted `model.User`. Now the user data is stored as an auth `models.Record`.
+21 -19
View File
@@ -1,5 +1,4 @@
Contributing to PocketBase
======================================================================
# Contributing to PocketBase
Thanks for taking the time to improve PocketBase!
@@ -9,26 +8,26 @@ This document describes how to prepare a PR for a change in the main repository.
- [Making changes in the Go code](#making-changes-in-the-go-code)
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
## Prerequisites
- Go 1.18+ (for making changes in the Go code)
- Go 1.19+ (for making changes in the Go code)
- Node 16+ (for making changes in the Admin UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
```
git clone https://github.com/your_username/pocketbase.git
```
> [!IMPORTANT]
> It is recommended to create a new branch from master for each of your bugfixes and features.
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
## Making changes in the Go code
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
The repository already includes such program, located in `examples/base`, that is also used for the prebuilt executables.
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
@@ -41,20 +40,22 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
To run the tests, you could execute (while in the root project directory):
```sh
go test ./...
# or using the Makefile
make test
```
```sh
go test ./...
# or using the Makefile
make test
```
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
```sh
golangci-lint run -c ./golangci.yml ./...
# or using the Makefile
make lint
```
```sh
golangci-lint run -c ./golangci.yml ./...
# or using the Makefile
make lint
```
## Making changes in the Admin UI
@@ -65,14 +66,15 @@ To start the Admin UI:
1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies
3. Start vite's dev server
```sh
npm run dev
```
```sh
npm run dev
```
You could open the browser and access the running Admin UI at `http://localhost:3000`.
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
> [!NOTE]
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
+3
View File
@@ -4,6 +4,9 @@ lint:
test:
go test ./... -v --cover
jstypes:
go run ./plugins/jsvm/internal/types/types.go
test-report:
go test ./... -v --cover -coverprofile=coverage.out
go tool cover -html=coverage.out
+14 -14
View File
@@ -19,10 +19,10 @@
**For documentation and examples, please visit https://pocketbase.io/docs.**
> ⚠️ Please keep in mind that PocketBase is still under active development
> [!WARNING]
> Please keep in mind that PocketBase is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## API SDK clients
The easiest way to interact with the API is to use one of the official SDK clients:
@@ -30,7 +30,6 @@ The easiest way to interact with the API is to use one of the official SDK clien
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
@@ -39,10 +38,9 @@ your own custom app specific business logic and still have a single portable exe
### Installation
```sh
# go 1.18+
# go 1.19+
go get github.com/pocketbase/pocketbase
```
> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
### Example
@@ -95,14 +93,15 @@ Enable CGO only if you really need to squeeze the read/write query performance a
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
0. [Install Go 1.19+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo
2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
(_https://go.dev/doc/install/source#environment_)
(_https://go.dev/doc/install/source#environment_)
4. Start the created executable by running `./base serve`.
The supported build targets by the non-cgo driver at the moment are:
```
darwin amd64
darwin arm64
@@ -114,6 +113,7 @@ linux arm
linux arm64
linux ppc64le
linux riscv64
linux s390x
windows amd64
windows arm64
```
@@ -122,6 +122,7 @@ windows arm64
PocketBase comes with mixed bag of unit and integration tests.
To run them, use the default `go test` command:
```sh
go test ./...
```
@@ -134,7 +135,6 @@ If you discover a security vulnerability within PocketBase, please send an e-mai
All reports will be promptly addressed, and you'll be credited accordingly.
## Contributing
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
@@ -146,14 +146,14 @@ You could help continuing its development by:
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us)
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
_Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
so all feature requests will be first aligned with what we need for Presentator v3._
> [!NOTE]
> PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
> so all feature requests will be first aligned with what we need for Presentator v3.
+66 -60
View File
@@ -33,18 +33,28 @@ type adminApi struct {
app core.App
}
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin, finalizers ...func(token string) error) error {
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
for _, f := range finalizers {
if err := f(token); err != nil {
return err
}
}
event := new(core.AdminAuthEvent)
event.HttpContext = c
event.Admin = admin
event.Token = token
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(200, map[string]any{
"token": e.Token,
"admin": e.Admin,
@@ -62,17 +72,11 @@ func (api *adminApi) authRefresh(c echo.Context) error {
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
return api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.app.OnAdminAfterAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
})
})
if handlerErr == nil {
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *adminApi) authWithPassword(c echo.Context) error {
@@ -95,17 +99,13 @@ func (api *adminApi) authWithPassword(c echo.Context) error {
return NewBadRequestError("Failed to authenticate.", err)
}
return api.authResponse(e.HttpContext, e.Admin)
return api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -130,29 +130,29 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Admin); err != nil && api.app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
// eagerly write 204 response and skip submit errors
// as a measure against admins enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
return submitErr
}
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
@@ -173,17 +173,17 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -208,6 +208,10 @@ func (api *adminApi) list(c echo.Context) error {
event.Result = result
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
@@ -228,6 +232,10 @@ func (api *adminApi) view(c echo.Context) error {
event.Admin = admin
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
}
@@ -256,17 +264,17 @@ func (api *adminApi) create(c echo.Context) error {
return NewBadRequestError("Failed to create admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return api.app.OnAdminAfterCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -302,17 +310,17 @@ func (api *adminApi) update(c echo.Context) error {
return NewBadRequestError("Failed to update admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return api.app.OnAdminAfterUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -331,19 +339,17 @@ func (api *adminApi) delete(c echo.Context) error {
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
return api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
return NewBadRequestError("Failed to delete admin.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnAdminAfterDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
+140
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -8,6 +9,7 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
@@ -89,6 +91,26 @@ func TestAdminAuthWithPassword(t *testing.T) {
"OnAdminAuthRequest": 1,
},
},
{
Name: "OnAdminAfterAuthWithPasswordRequest error response",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -224,6 +246,29 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
"OnAdminAfterConfirmPasswordResetRequest": 1,
},
},
{
Name: "OnAdminAfterConfirmPasswordResetRequest error response",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"password":"1234567891",
"passwordConfirm":"1234567891"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminBeforeConfirmPasswordResetRequest": 1,
"OnAdminAfterConfirmPasswordResetRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -278,6 +323,25 @@ func TestAdminRefresh(t *testing.T) {
"OnAdminAfterAuthRefreshRequest": 1,
},
},
{
Name: "OnAdminAfterAuthRefreshRequest error response",
Method: http.MethodPost,
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthRefreshRequest": 1,
"OnAdminAfterAuthRefreshRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -510,6 +574,27 @@ func TestAdminDelete(t *testing.T) {
"OnAdminBeforeDeleteRequest": 1,
},
},
{
Name: "OnAdminAfterDeleteRequest error response",
Method: http.MethodDelete,
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterDeleteRequest().Add(func(e *core.AdminDeleteEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
"OnAdminBeforeDeleteRequest": 1,
"OnAdminAfterDeleteRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -637,6 +722,33 @@ func TestAdminCreate(t *testing.T) {
"OnAdminAfterCreateRequest": 1,
},
},
{
Name: "OnAdminAfterCreateRequest error response",
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567890",
"passwordConfirm":"1234567890",
"avatar":3
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterCreateRequest().Add(func(e *core.AdminCreateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnAdminBeforeCreateRequest": 1,
"OnAdminAfterCreateRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -729,6 +841,7 @@ func TestAdminUpdate(t *testing.T) {
},
},
{
Name: "authorized as admin + valid data",
Method: http.MethodPatch,
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
@@ -759,6 +872,33 @@ func TestAdminUpdate(t *testing.T) {
"OnAdminAfterUpdateRequest": 1,
},
},
{
Name: "OnAdminAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567891",
"passwordConfirm":"1234567891",
"avatar":5
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnAdminAfterUpdateRequest().Add(func(e *core.AdminUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminBeforeUpdateRequest": 1,
"OnAdminAfterUpdateRequest": 1,
},
},
}
for _, scenario := range scenarios {
+50 -26
View File
@@ -66,43 +66,67 @@ func NewUnauthorizedError(message string, data any) *ApiError {
// NewApiError creates and returns new normalized `ApiError` instance.
func NewApiError(status int, message string, data any) *ApiError {
message = inflector.Sentenize(message)
formattedData := map[string]any{}
if v, ok := data.(validation.Errors); ok {
formattedData = resolveValidationErrors(v)
}
return &ApiError{
rawData: data,
Data: formattedData,
Data: safeErrorsData(data),
Code: status,
Message: strings.TrimSpace(message),
Message: strings.TrimSpace(inflector.Sentenize(message)),
}
}
func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
func safeErrorsData(data any) map[string]any {
switch v := data.(type) {
case validation.Errors:
return resolveSafeErrorsData[error](v)
case map[string]validation.Error:
return resolveSafeErrorsData[validation.Error](v)
case map[string]error:
return resolveSafeErrorsData[error](v)
case map[string]any:
return resolveSafeErrorsData[any](v)
default:
return map[string]any{} // not nil to ensure that is json serialized as object
}
}
func resolveSafeErrorsData[T any](data map[string]T) map[string]any {
result := map[string]any{}
// extract from each validation error its error code and message.
for name, err := range validationErrors {
// check for nested errors
if nestedErrs, ok := err.(validation.Errors); ok {
result[name] = resolveValidationErrors(nestedErrs)
for name, err := range data {
if isNestedError(err) {
result[name] = safeErrorsData(err)
continue
}
errCode := "validation_invalid_value" // default
if errObj, ok := err.(validation.ErrorObject); ok {
errCode = errObj.Code()
}
result[name] = map[string]string{
"code": errCode,
"message": inflector.Sentenize(err.Error()),
}
result[name] = resolveSafeErrorItem(err)
}
return result
}
func isNestedError(err any) bool {
switch err.(type) {
case validation.Errors, map[string]validation.Error, map[string]error, map[string]any:
return true
}
return false
}
// resolveSafeErrorItem extracts from each validation error its
// public safe error code and message.
func resolveSafeErrorItem(err any) map[string]string {
// default public safe error values
code := "validation_invalid_value"
msg := "Invalid value."
// only validation errors are public safe
if obj, ok := err.(validation.Error); ok {
code = obj.Code()
msg = inflector.Sentenize(obj.Error())
}
return map[string]string{
"code": code,
"message": msg,
}
}
+12 -12
View File
@@ -37,10 +37,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
300,
"message_test",
validation.Errors{
"err1": errors.New("test error"),
"err1": errors.New("test error"), // should be normalized
"err2": validation.ErrRequired,
"err3": validation.Errors{
"sub1": errors.New("test error"),
"sub1": errors.New("test error"), // should be normalized
"sub2": validation.ErrRequired,
"sub3": validation.Errors{
"sub11": validation.ErrRequired,
@@ -50,10 +50,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
)
result, _ := json.Marshal(e)
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Invalid value."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
if string(result) != expected {
t.Errorf("Expected %v, got %v", expected, string(result))
t.Errorf("Expected \n%v, \ngot \n%v", expected, string(result))
}
if e.Error() != "Message_test." {
@@ -73,7 +73,7 @@ func TestNewNotFoundError(t *testing.T) {
}{
{"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
{"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -81,7 +81,7 @@ func TestNewNotFoundError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
@@ -94,7 +94,7 @@ func TestNewBadRequestError(t *testing.T) {
}{
{"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
{"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -102,7 +102,7 @@ func TestNewBadRequestError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
@@ -115,7 +115,7 @@ func TestNewForbiddenError(t *testing.T) {
}{
{"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
{"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -123,7 +123,7 @@ func TestNewForbiddenError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
@@ -136,7 +136,7 @@ func TestNewUnauthorizedError(t *testing.T) {
}{
{"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
{"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
}
for i, scenario := range scenarios {
@@ -144,7 +144,7 @@ func TestNewUnauthorizedError(t *testing.T) {
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
}
}
}
+215
View File
@@ -0,0 +1,215 @@
package apis
import (
"context"
"log"
"net/http"
"path/filepath"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// bindBackupApi registers the file api endpoints and the corresponding handlers.
//
// @todo add hooks once the app hooks api restructuring is finalized
func bindBackupApi(app core.App, rg *echo.Group) {
api := backupApi{app: app}
subGroup := rg.Group("/backups", ActivityLogger(app))
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuth())
subGroup.POST("/upload", api.upload, RequireAdminAuth())
subGroup.GET("/:key", api.download)
subGroup.DELETE("/:key", api.delete, RequireAdminAuth())
subGroup.POST("/:key/restore", api.restore, RequireAdminAuth())
}
type backupApi struct {
app core.App
}
func (api *backupApi) list(c echo.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
backups, err := fsys.List("")
if err != nil {
return NewBadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
}
result := make([]models.BackupFileInfo, len(backups))
for i, obj := range backups {
modified, _ := types.ParseDateTime(obj.ModTime)
result[i] = models.BackupFileInfo{
Key: obj.Key,
Size: obj.Size,
Modified: modified,
}
}
return c.JSON(http.StatusOK, result)
}
func (api *backupApi) create(c echo.Context) error {
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
}
form := forms.NewBackupCreate(api.app)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
return form.Submit(func(next forms.InterceptorNextFunc[string]) forms.InterceptorNextFunc[string] {
return func(name string) error {
if err := next(name); err != nil {
return NewBadRequestError("Failed to create backup.", err)
}
// we don't retrieve the generated backup file because it may not be
// available yet due to the eventually consistent nature of some S3 providers
return c.NoContent(http.StatusNoContent)
}
})
}
func (api *backupApi) upload(c echo.Context) error {
files, err := rest.FindUploadedFiles(c.Request(), "file")
if err != nil {
return NewBadRequestError("Missing or invalid uploaded file.", err)
}
form := forms.NewBackupUpload(api.app)
form.File = files[0]
return form.Submit(func(next forms.InterceptorNextFunc[*filesystem.File]) forms.InterceptorNextFunc[*filesystem.File] {
return func(file *filesystem.File) error {
if err := next(file); err != nil {
return NewBadRequestError("Failed to upload backup.", err)
}
// we don't retrieve the generated backup file because it may not be
// available yet due to the eventually consistent nature of some S3 providers
return c.NoContent(http.StatusNoContent)
}
})
}
func (api *backupApi) download(c echo.Context) error {
fileToken := c.QueryParam("token")
_, err := api.app.Dao().FindAdminByToken(
fileToken,
api.app.Settings().AdminFileToken.Secret,
)
if err != nil {
return NewForbiddenError("Insufficient permissions to access the resource.", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
key := c.PathParam("key")
br, err := fsys.GetFile(key)
if err != nil {
return NewBadRequestError("Failed to retrieve backup item. Raw error: \n"+err.Error(), nil)
}
defer br.Close()
return fsys.Serve(
c.Response(),
c.Request(),
key,
filepath.Base(key), // without the path prefix (if any)
)
}
func (api *backupApi) restore(c echo.Context) error {
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
}
key := c.PathParam("key")
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(existsCtx)
if exists, err := fsys.Exists(key); !exists {
return NewBadRequestError("Missing or invalid backup file.", err)
}
go func() {
// wait max 15 minutes to fetch the backup
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
// give some optimistic time to write the response
time.Sleep(1 * time.Second)
if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() {
log.Println(err)
}
}()
return c.NoContent(http.StatusNoContent)
}
func (api *backupApi) delete(c echo.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
key := c.PathParam("key")
if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key {
return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
}
if err := fsys.Delete(key); err != nil {
return NewBadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
}
+758
View File
@@ -0,0 +1,758 @@
package apis_test
import (
"archive/zip"
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"gocloud.dev/blob"
)
func TestBackupsList(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/backups",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (empty list)",
Method: http.MethodGet,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`[]`,
},
},
{
Name: "authorized as admin",
Method: http.MethodGet,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"test1.zip"`,
`"test2.zip"`,
`"test3.zip"`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsCreate(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/backups",
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (pending backup)",
Method: http.MethodPost,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.Cache().Set(core.CacheKeyActiveBackup, "")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (autogenerated name)",
Method: http.MethodPost,
Url: "/api/backups",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 backup file, got %d", total)
}
expected := "pb_backup_"
if !strings.HasPrefix(files[0].Key, expected) {
t.Fatalf("Expected backup file with prefix %q, got %q", expected, files[0].Key)
}
},
ExpectedStatus: 204,
},
{
Name: "authorized as admin (invalid name)",
Method: http.MethodPost,
Url: "/api/backups",
Body: strings.NewReader(`{"name":"!test.zip"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"name":{"code":"validation_match_invalid"`,
},
},
{
Name: "authorized as admin (valid name)",
Method: http.MethodPost,
Url: "/api/backups",
Body: strings.NewReader(`{"name":"test.zip"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 backup file, got %d", total)
}
expected := "test.zip"
if files[0].Key != expected {
t.Fatalf("Expected backup file %q, got %q", expected, files[0].Key)
}
},
ExpectedStatus: 204,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsUpload(t *testing.T) {
// create dummy form data bodies
type body struct {
buffer io.Reader
contentType string
}
bodies := make([]body, 10)
for i := 0; i < 10; i++ {
func() {
zb := new(bytes.Buffer)
zw := zip.NewWriter(zb)
if err := zw.Close(); err != nil {
t.Fatal(err)
}
b := new(bytes.Buffer)
mw := multipart.NewWriter(b)
mfw, err := mw.CreateFormFile("file", "test")
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(mfw, zb); err != nil {
t.Fatal(err)
}
mw.Close()
bodies[i] = body{
buffer: b,
contentType: mw.FormDataContentType(),
}
}()
}
// ---
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[0].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[0].contentType,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[1].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[1].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (missing file)",
Method: http.MethodPost,
Url: "/api/backups/upload",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{`},
},
{
Name: "authorized as admin (existing backup name)",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[3].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[3].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
fsys, err := app.NewBackupsFilesystem()
if err != nil {
t.Fatal(err)
}
defer fsys.Close()
// create a dummy backup file to simulate existing backups
if err := fsys.Upload([]byte("123"), "test"); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"file":{`},
},
{
Name: "authorized as admin (valid file)",
Method: http.MethodPost,
Url: "/api/backups/upload",
Body: bodies[4].buffer,
RequestHeaders: map[string]string{
"Content-Type": bodies[4].contentType,
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
}
},
ExpectedStatus: 204,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsDownload(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/backups/test1.zip",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with record auth header",
Method: http.MethodGet,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with admin auth header",
Method: http.MethodGet,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with empty or invalid token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid record auth token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid record file token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid admin auth token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with expired admin file token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid admin file token but missing backup name",
Method: http.MethodGet,
Url: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "with valid admin file token",
Method: http.MethodGet,
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`storage/`,
`data.db`,
`logs.db`,
},
},
{
Name: "with valid admin file token and backup name with escaped char",
Method: http.MethodGet,
Url: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`storage/`,
`data.db`,
`logs.db`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsDelete(t *testing.T) {
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
expected := 4
if total := len(files); total != expected {
t.Fatalf("Expected %d backup(s), got %d", expected, total)
}
}
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (missing file)",
Method: http.MethodDelete,
Url: "/api/backups/missing.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (existing file with matching active backup)",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
// mock active backup with the same name to delete
app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (existing file and no matching active backup)",
Method: http.MethodDelete,
Url: "/api/backups/test1.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
// mock active backup with different name
app.Cache().Set(core.CacheKeyActiveBackup, "new.zip")
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 3 {
t.Fatalf("Expected %d backup files, got %d", 3, total)
}
deletedFile := "test1.zip"
for _, f := range files {
if f.Key == deletedFile {
t.Fatalf("Expected backup %q to be deleted", deletedFile)
}
}
},
ExpectedStatus: 204,
},
{
Name: "authorized as admin (backup with escaped character)",
Method: http.MethodDelete,
Url: "/api/backups/%40test4.zip",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 3 {
t.Fatalf("Expected %d backup files, got %d", 3, total)
}
deletedFile := "@test4.zip"
for _, f := range files {
if f.Key == deletedFile {
t.Fatalf("Expected backup %q to be deleted", deletedFile)
}
}
},
ExpectedStatus: 204,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestBackupsRestore(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/backups/test1.zip/restore",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/backups/test1.zip/restore",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (missing file)",
Method: http.MethodPost,
Url: "/api/backups/missing.zip/restore",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (active backup process)",
Method: http.MethodPost,
Url: "/api/backups/test1.zip/restore",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
app.Cache().Set(core.CacheKeyActiveBackup, "")
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// -------------------------------------------------------------------
func createTestBackups(app core.App) error {
ctx := context.Background()
if err := app.CreateBackup(ctx, "test1.zip"); err != nil {
return err
}
if err := app.CreateBackup(ctx, "test2.zip"); err != nil {
return err
}
if err := app.CreateBackup(ctx, "test3.zip"); err != nil {
return err
}
if err := app.CreateBackup(ctx, "@test4.zip"); err != nil {
return err
}
return nil
}
func getBackupFiles(app core.App) ([]*blob.ListObject, error) {
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return nil, err
}
defer fsys.Close()
return fsys.List("")
}
func ensureNoBackups(t *testing.T, app *tests.TestApp) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
}
if total := len(files); total != 0 {
t.Fatalf("Expected 0 backup files, got %d", total)
}
}
+66 -39
View File
@@ -2,6 +2,7 @@
package apis
import (
"database/sql"
"errors"
"fmt"
"io/fs"
@@ -14,6 +15,7 @@ import (
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/ui"
"github.com/spf13/cast"
)
@@ -25,11 +27,15 @@ const trailedAdminPath = "/_/"
func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New()
e.Debug = app.IsDebug()
e.JSONSerializer = &rest.Serializer{
FieldsParam: "fields",
}
// configure a custom router
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
return echo.NewRouter(echo.RouterConfig{
UnescapePathParamValues: true,
AllowOverwritingRoute: true,
})
})
@@ -40,12 +46,16 @@ func InitApi(app core.App) (*echo.Echo, error) {
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
},
}))
e.Pre(LoadAuthContext(app))
e.Use(middleware.Recover())
e.Use(middleware.Secure())
e.Use(LoadAuthContext(app))
// custom error handler
e.HTTPErrorHandler = func(c echo.Context, err error) {
if err == nil {
return // no error
}
if c.Response().Committed {
if app.IsDebug() {
log.Println("HTTPErrorHandler response was already committed:", err)
@@ -55,23 +65,26 @@ func InitApi(app core.App) (*echo.Echo, error) {
var apiErr *ApiError
switch v := err.(type) {
case *echo.HTTPError:
if errors.As(err, &apiErr) {
if app.IsDebug() && apiErr.RawData() != nil {
log.Println(apiErr.RawData())
}
} else if v := new(echo.HTTPError); errors.As(err, &v) {
if v.Internal != nil && app.IsDebug() {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message)
apiErr = NewApiError(v.Code, msg, v)
case *ApiError:
if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData())
}
apiErr = v
default:
if err != nil && app.IsDebug() {
} else {
if app.IsDebug() {
log.Println(err)
}
apiErr = NewBadRequestError("", err)
if errors.Is(err, sql.ErrNoRows) {
apiErr = NewNotFoundError("", err)
} else {
apiErr = NewBadRequestError("", err)
}
}
event := new(core.ApiErrorEvent)
@@ -80,6 +93,10 @@ func InitApi(app core.App) (*echo.Echo, error) {
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
if c.Response().Committed {
return nil
}
// @see https://github.com/labstack/echo/issues/608
if e.HttpContext.Request().Method == http.MethodHead {
return e.HttpContext.NoContent(apiErr.Code)
@@ -88,19 +105,21 @@ func InitApi(app core.App) (*echo.Echo, error) {
return e.HttpContext.JSON(apiErr.Code, apiErr)
})
// truly rare case; eg. client already disconnected
if hookErr != nil && app.IsDebug() {
if hookErr == nil {
if err := app.OnAfterApiError().Trigger(event); err != nil && app.IsDebug() {
log.Println(hookErr)
}
} else if app.IsDebug() {
// truly rare case; eg. client already disconnected
log.Println(hookErr)
}
app.OnAfterApiError().Trigger(event)
}
// admin ui routes
bindStaticAdminUI(app, e)
// default routes
api := e.Group("/api")
api := e.Group("/api", eagerRequestInfoCache(app))
bindSettingsApi(app, api)
bindAdminApi(app, api)
bindCollectionApi(app, api)
@@ -110,16 +129,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
bindRealtimeApi(app, api)
bindLogsApi(app, api)
bindHealthApi(app, api)
// trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes
serveEvent := &core.ServeEvent{
App: app,
Router: e,
}
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
return nil, err
}
bindBackupApi(app, api)
// catch all any route
api.Any("/*", func(c echo.Context) error {
@@ -176,21 +186,36 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
trailedAdminPath+"*",
echo.StaticDirectoryHandler(ui.DistDirFS, false),
installerRedirect(app),
uiCacheControl(),
middleware.Gzip(),
)
return nil
}
const totalAdminsCacheKey = "totalAdmins"
func uiCacheControl() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// add default Cache-Control header for all Admin UI resources
// (ignoring the root admin path)
if c.Request().URL.Path != trailedAdminPath {
c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
func updateTotalAdminsCache(app core.App) error {
return next(c)
}
}
}
const hasAdminsCacheKey = "@hasAdmins"
func updateHasAdminsCache(app core.App) error {
total, err := app.Dao().TotalAdmins()
if err != nil {
return err
}
app.Cache().Set(totalAdminsCacheKey, total)
app.Cache().Set(hasAdminsCacheKey, total > 0)
return nil
}
@@ -198,12 +223,13 @@ func updateTotalAdminsCache(app core.App) error {
// installerRedirect redirects the user to the installer admin UI page
// when the application needs some preliminary configurations to be done.
func installerRedirect(app core.App) echo.MiddlewareFunc {
// keep totalAdminsCacheKey value up-to-date
// keep hasAdminsCacheKey value up-to-date
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
return updateTotalAdminsCache(app)
return updateHasAdminsCache(app)
})
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
return updateTotalAdminsCache(app)
return updateHasAdminsCache(app)
})
return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -214,23 +240,24 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
return next(c)
}
// load into cache (if not already)
if !app.Cache().Has(totalAdminsCacheKey) {
if err := updateTotalAdminsCache(app); err != nil {
hasAdmins := cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
if !hasAdmins {
// update the cache to make sure that the admin wasn't created by another process
if err := updateHasAdminsCache(app); err != nil {
return err
}
hasAdmins = cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
}
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
_, hasInstallerParam := c.Request().URL.Query()["installer"]
if totalAdmins == 0 && !hasInstallerParam {
if !hasAdmins && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
if hasAdmins && hasInstallerParam {
// clear the installer param
return c.Redirect(http.StatusTemporaryRedirect, "?")
}
+190
View File
@@ -1,13 +1,17 @@
package apis_test
import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
"github.com/spf13/cast"
)
func Test404(t *testing.T) {
@@ -209,3 +213,189 @@ func TestRemoveTrailingSlashMiddleware(t *testing.T) {
scenario.Test(t)
}
}
func TestEagerRequestInfoCache(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "custom non-api group route",
Method: "POST",
Url: "/custom",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: "POST",
Path: "/custom",
Handler: func(c echo.Context) error {
data := &struct {
Name string `json:"name"`
}{}
if err := c.Bind(data); err != nil {
return err
}
// since the unknown method is not eager cache support
// it should fail reading the json body twice
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "" {
t.Fatalf("Expected empty request data body, got, %v", r.Data)
}
return c.NoContent(200)
},
})
},
ExpectedStatus: 200,
},
{
Name: "api group route with unsupported eager cache request method",
Method: "GET",
Url: "/api/admins",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// it is not important whether the route handler return an error since
// we just need to ensure that the eagerRequestInfoCache was registered
next(c)
// ensure that the body was read at least once
data := &struct {
Name string `json:"name"`
}{}
c.Bind(data)
// since the unknown method is not eager cache support
// it should fail reading the json body twice
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "" {
t.Fatalf("Expected empty request data body, got, %v", r.Data)
}
return nil
}
})
},
ExpectedStatus: 200,
},
{
Name: "api group route with supported eager cache request method",
Method: "POST",
Url: "/api/admins",
Body: strings.NewReader(`{"name":"test123"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// it is not important whether the route handler return an error since
// we just need to ensure that the eagerRequestInfoCache was registered
next(c)
// ensure that the body was read at least once
data := &struct {
Name string `json:"name"`
}{}
c.Bind(data)
// try to read the body again
r := apis.RequestInfo(c)
if v := cast.ToString(r.Data["name"]); v != "test123" {
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
}
return nil
}
})
},
ExpectedStatus: 200,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestErrorHandler(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "apis.ApiError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return apis.NewApiError(418, "test", nil)
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
},
{
Name: "wrapped apis.ApiError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123: %w", apis.NewApiError(418, "test", nil))
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
NotExpectedContent: []string{"example", "123"},
},
{
Name: "echo.HTTPError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return echo.NewHTTPError(418, "test")
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
},
{
Name: "wrapped echo.HTTPError",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123: %w", echo.NewHTTPError(418, "test"))
})
},
ExpectedStatus: 418,
ExpectedContent: []string{`"message":"Test."`},
NotExpectedContent: []string{"example", "123"},
},
{
Name: "wrapped sql.ErrNoRows",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123: %w", sql.ErrNoRows)
})
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
NotExpectedContent: []string{"example", "123"},
},
{
Name: "custom error",
Method: http.MethodGet,
Url: "/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.GET("/test", func(c echo.Context) error {
return fmt.Errorf("example 123")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
NotExpectedContent: []string{"example", "123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+40 -41
View File
@@ -1,7 +1,6 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
@@ -49,6 +48,10 @@ func (api *collectionApi) list(c echo.Context) error {
event.Result = result
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
@@ -64,6 +67,10 @@ func (api *collectionApi) view(c echo.Context) error {
event.Collection = collection
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
}
@@ -83,7 +90,7 @@ func (api *collectionApi) create(c echo.Context) error {
event.Collection = collection
// create the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
@@ -92,18 +99,16 @@ func (api *collectionApi) create(c echo.Context) error {
return NewBadRequestError("Failed to create the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
return api.app.OnCollectionAfterCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
})
}
})
if submitErr == nil {
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *collectionApi) update(c echo.Context) error {
@@ -124,7 +129,7 @@ func (api *collectionApi) update(c echo.Context) error {
event.Collection = collection
// update the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(m *models.Collection) error {
event.Collection = m
@@ -133,18 +138,16 @@ func (api *collectionApi) update(c echo.Context) error {
return NewBadRequestError("Failed to update the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
return api.app.OnCollectionAfterUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
})
}
})
if submitErr == nil {
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *collectionApi) delete(c echo.Context) error {
@@ -157,21 +160,19 @@ func (api *collectionApi) delete(c echo.Context) error {
event.HttpContext = c
event.Collection = collection
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
return api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnCollectionAfterDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *collectionApi) bulkImport(c echo.Context) error {
@@ -187,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
event.Collections = form.Collections
// import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func(imports []*models.Collection) error {
event.Collections = imports
@@ -196,16 +197,14 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
return NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnCollectionsAfterImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
+140 -104
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"os"
"path/filepath"
@@ -9,8 +10,8 @@ import (
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -45,7 +46,7 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":10`,
`"totalItems":11`,
`"items":[{`,
`"id":"_pb_users_auth_"`,
`"id":"v851q4r790rhknl"`,
@@ -55,6 +56,7 @@ func TestCollectionsList(t *testing.T) {
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"9n89pl5vkct6330"`,
`"id":"ib3m2700k5hlsjz"`,
`"type":"auth"`,
`"type":"base"`,
},
@@ -73,9 +75,9 @@ func TestCollectionsList(t *testing.T) {
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
`"totalItems":10`,
`"totalItems":11`,
`"items":[{`,
`"id":"kpv709sk2lqbqk8"`,
`"id":"v9gwnfh02gjq1q0"`,
`"id":"9n89pl5vkct6330"`,
},
ExpectedEvents: map[string]int{
@@ -243,7 +245,7 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
@@ -262,7 +264,7 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
@@ -299,7 +301,6 @@ func TestCollectionDelete(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
@@ -308,6 +309,27 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionAfterDeleteRequest": 1,
},
},
{
Name: "OnCollectionAfterDeleteRequest error response",
Method: http.MethodDelete,
Url: "/api/collections/view2",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionAfterDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -378,7 +400,7 @@ func TestCollectionCreate(t *testing.T) {
`"name":"new"`,
`"type":"base"`,
`"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"options":{}`,
},
ExpectedEvents: map[string]int{
@@ -402,7 +424,7 @@ func TestCollectionCreate(t *testing.T) {
`"name":"new"`,
`"type":"auth"`,
`"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"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}`,
},
ExpectedEvents: map[string]int{
@@ -536,6 +558,28 @@ func TestCollectionCreate(t *testing.T) {
`"options":{"minPasswordLength":{"code":"validation_required"`,
},
},
{
Name: "OnCollectionAfterCreateRequest error response",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionAfterCreateRequest().Add(func(e *core.CollectionCreateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
},
// view
// -----------------------------------------------------------
@@ -649,7 +693,7 @@ func TestCollectionCreate(t *testing.T) {
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
@@ -720,6 +764,28 @@ func TestCollectionUpdate(t *testing.T) {
"OnModelBeforeUpdate": 1,
},
},
{
Name: "OnCollectionAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionAfterUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnCollectionAfterUpdateRequest": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
{
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPatch,
@@ -757,7 +823,7 @@ func TestCollectionUpdate(t *testing.T) {
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
// check if the record table was renamed
if !app.Dao().HasTable("new") {
t.Fatal("Couldn't find record table 'new'.")
@@ -893,88 +959,6 @@ func TestCollectionUpdate(t *testing.T) {
},
},
// rel field change displayFields propagation
// -----------------------------------------------------------
{
Name: "renaming a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"id": "w5z2x0nq",
"type": "text",
"name": "title_change"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"title_change"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"title_change", "id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
{
Name: "deleting a display field should also update the referenced displayFields value",
Method: http.MethodPatch,
Url: "/api/collections/demo3",
Body: strings.NewReader(`{
"schema":[
{
"type": "text",
"name": "new_field"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new_field"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
options := relField.Options.(*schema.RelationOptions)
expectedDisplayFields := []string{"id"}
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
}
},
},
// view
// -----------------------------------------------------------
{
@@ -1076,7 +1060,7 @@ func TestCollectionUpdate(t *testing.T) {
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
@@ -1097,8 +1081,8 @@ func TestCollectionUpdate(t *testing.T) {
}
}
func TestCollectionImport(t *testing.T) {
totalCollections := 10
func TestCollectionsImport(t *testing.T) {
totalCollections := 11
scenarios := []tests.ApiScenario{
{
@@ -1131,7 +1115,7 @@ func TestCollectionImport(t *testing.T) {
`"data":{`,
`"collections":{"code":"validation_required"`,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1157,9 +1141,9 @@ func TestCollectionImport(t *testing.T) {
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 7,
"OnModelBeforeDelete": 4,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1201,7 +1185,7 @@ func TestCollectionImport(t *testing.T) {
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeCreate": 2,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1257,7 +1241,7 @@ func TestCollectionImport(t *testing.T) {
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1355,14 +1339,14 @@ func TestCollectionImport(t *testing.T) {
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 8,
"OnModelAfterDelete": 8,
"OnModelBeforeDelete": 9,
"OnModelAfterDelete": 9,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
@@ -1373,6 +1357,58 @@ func TestCollectionImport(t *testing.T) {
}
},
},
{
Name: "authorized as admin + successful collections save",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
},
{
"name": "auth_without_schema",
"type": "auth"
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnCollectionsAfterImportRequest": 1,
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
},
},
}
for _, scenario := range scenarios {
+99 -4
View File
@@ -1,13 +1,19 @@
package apis
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
@@ -18,6 +24,7 @@ func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app}
subGroup := rg.Group("/files", ActivityLogger(app))
subGroup.POST("/token", api.fileToken)
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
}
@@ -26,6 +33,35 @@ type fileApi struct {
app core.App
}
func (api *fileApi) fileToken(c echo.Context) error {
event := new(core.FileTokenEvent)
event.HttpContext = c
if admin, _ := c.Get(ContextAdminKey).(*models.Admin); admin != nil {
event.Model = admin
event.Token, _ = tokens.NewAdminFileToken(api.app, admin)
} else if record, _ := c.Get(ContextAuthRecordKey).(*models.Record); record != nil {
event.Model = record
event.Token, _ = tokens.NewRecordFileToken(api.app, record)
}
return api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
if e.Model == nil || e.Token == "" {
return NewBadRequestError("Failed to generate file token.", nil)
}
return api.app.OnFileAfterTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, map[string]string{
"token": e.Token,
})
})
})
}
func (api *fileApi) download(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
@@ -49,7 +85,33 @@ func (api *fileApi) download(c echo.Context) error {
return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
options, ok := fileField.Options.(*schema.FileOptions)
if !ok {
return NewBadRequestError("", errors.New("Failed to load file options."))
}
// check whether the request is authorized to view the protected file
if options.Protected {
token := c.QueryParam("token")
adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token)
// create a copy of the cached request data and adjust it for the current auth model
requestInfo := *RequestInfo(c)
requestInfo.Admin = nil
requestInfo.AuthRecord = nil
if adminOrAuthRecord != nil {
if admin, _ := adminOrAuthRecord.(*models.Admin); admin != nil {
requestInfo.Admin = admin
} else if record, _ := adminOrAuthRecord.(*models.Record); record != nil {
requestInfo.AuthRecord = record
}
}
if ok, _ := api.app.Dao().CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
}
}
baseFilesPath := record.BaseFilesPath()
@@ -110,12 +172,45 @@ func (api *fileApi) download(c echo.Context) error {
c.Response().Header().Del("X-Frame-Options")
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
res := e.HttpContext.Response()
req := e.HttpContext.Request()
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
if e.HttpContext.Response().Committed {
return nil
}
if err := fs.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
return NewNotFoundError("", err)
}
return nil
})
}
func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.Model, error) {
fileToken = strings.TrimSpace(fileToken)
if fileToken == "" {
return nil, errors.New("missing file token")
}
claims, _ := security.ParseUnverifiedJWT(strings.TrimSpace(fileToken))
tokenType := cast.ToString(claims["type"])
switch tokenType {
case tokens.TypeAdmin:
admin, err := api.app.Dao().FindAdminByToken(
fileToken,
api.app.Settings().AdminFileToken.Secret,
)
if err == nil && admin != nil {
return admin, nil
}
case tokens.TypeAuthRecord:
record, err := api.app.Dao().FindAuthRecordByToken(
fileToken,
api.app.Settings().RecordFileToken.Secret,
)
if err == nil && record != nil {
return record, nil
}
}
return nil, errors.New("missing or invalid file token")
}
+195
View File
@@ -8,9 +8,85 @@ import (
"runtime"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestFileToken(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/files/token",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
},
},
{
Name: "unauthorized with model and token via hook",
Method: http.MethodPost,
Url: "/api/files/token",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error {
record, _ := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
e.Model = record
e.Token = "test"
return nil
})
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"test"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
{
Name: "auth record",
Method: http.MethodPost,
Url: "/api/files/token",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
{
Name: "admin",
Method: http.MethodPost,
Url: "/api/files/token",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
},
ExpectedEvents: map[string]int{
"OnFileBeforeTokenRequest": 1,
"OnFileAfterTokenRequest": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
@@ -176,6 +252,125 @@ func TestFileDownload(t *testing.T) {
"OnFileDownloadRequest": 1,
},
},
// protected file access checks
{
Name: "protected file - expired token",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - admin with expired file token",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - admin with valid file token",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - guest without view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - guest with view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock public view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - auth record without view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock restricted user view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = true")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file - auth record with view access",
Method: http.MethodGet,
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
dao := daos.New(app.Dao().DB())
// mock user view access
c, err := dao.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = false")
if err := dao.SaveCollection(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file in view (view's View API rule failure)",
Method: http.MethodGet,
Url: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "protected file in view (view's View API rule success)",
Method: http.MethodGet,
Url: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
ExpectedStatus: 200,
ExpectedContent: []string{"test"},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
}
for _, scenario := range scenarios {
+13 -5
View File
@@ -19,12 +19,20 @@ type healthApi struct {
app core.App
}
type healthCheckResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
CanBackup bool `json:"canBackup"`
} `json:"data"`
}
// healthCheck returns a 200 OK response if the server is healthy.
func (api *healthApi) healthCheck(c echo.Context) error {
payload := map[string]any{
"code": http.StatusOK,
"message": "API is healthy.",
}
resp := new(healthCheckResponse)
resp.Code = http.StatusOK
resp.Message = "API is healthy."
resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup)
return c.JSON(http.StatusOK, payload)
return c.JSON(http.StatusOK, resp)
}
+2
View File
@@ -16,6 +16,8 @@ func TestHealthAPI(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{
`"code":200`,
`"data":{`,
`"canBackup":true`,
},
},
}
+34 -9
View File
@@ -129,14 +129,17 @@ func RequireAdminAuth() echo.MiddlewareFunc {
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return next(c)
}
totalAdmins, err := app.Dao().TotalAdmins()
if err != nil {
return NewBadRequestError("Failed to fetch admins info.", err)
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil || totalAdmins == 0 {
if totalAdmins == 0 {
return next(c)
}
@@ -284,8 +287,10 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
return func(c echo.Context) error {
err := next(c)
logsMaxDays := app.Settings().Logs.MaxDays
// no logs retention
if app.Settings().Logs.MaxDays == 0 {
if logsMaxDays == 0 {
return err
}
@@ -345,8 +350,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
if daysDiff > float64(app.Settings().Logs.MaxDays) {
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
if 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() {
@@ -368,15 +373,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
return ip
}
if ip := r.Header.Get("Fly-Client-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
// extract the first non-empty leftmost-ish ip
ips := strings.Split(ipsList, ",")
// extract the rightmost ip
for i := len(ips) - 1; i >= 0; i-- {
ip := strings.TrimSpace(ips[i])
for _, ip := range ips {
ip = strings.TrimSpace(ip)
if ip != "" {
return ip
}
@@ -385,3 +394,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
return fallbackIp
}
// eagerRequestInfoCache ensures that the request data is cached in the request
// context to allow reading for example the json request body data more than once.
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
switch c.Request().Method {
// currently we are eagerly caching only the requests with body
case "POST", "PUT", "PATCH", "DELETE":
RequestInfo(c)
}
return next(c)
}
}
}
+116 -59
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v5"
@@ -56,9 +56,8 @@ func (api *realtimeApi) connect(c echo.Context) error {
api.app.SubscriptionsBroker().Unregister(client.Id())
}()
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-store")
c.Response().Header().Set("Connection", "keep-alive")
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
c.Response().Header().Set("X-Accel-Buffering", "no")
@@ -66,6 +65,7 @@ func (api *realtimeApi) connect(c echo.Context) error {
connectEvent := &core.RealtimeConnectEvent{
HttpContext: c,
Client: client,
IdleTimeout: 5 * time.Minute,
}
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
@@ -82,16 +82,18 @@ func (api *realtimeApi) connect(c echo.Context) error {
Client: client,
Message: &subscriptions.Message{
Name: "PB_CONNECT",
Data: `{"clientId":"` + client.Id() + `"}`,
Data: []byte(`{"clientId":"` + client.Id() + `"}`),
},
}
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
w := e.HttpContext.Response()
fmt.Fprint(w, "id:"+client.Id()+"\n")
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
w.Write([]byte("id:" + client.Id() + "\n"))
w.Write([]byte("event:" + e.Message.Name + "\n"))
w.Write([]byte("data:"))
w.Write(e.Message.Data)
w.Write([]byte("\n\n"))
w.Flush()
return nil
return api.app.OnRealtimeAfterMessageSend().Trigger(e)
})
if connectMsgErr != nil {
if api.app.IsDebug() {
@@ -99,13 +101,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
}
return nil
}
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
}
// start an idle timer to keep track of inactive/forgotten connections
idleDuration := 5 * time.Minute
idleTimer := time.NewTimer(idleDuration)
idleTimeout := connectEvent.IdleTimeout
idleTimer := time.NewTimer(idleTimeout)
defer idleTimer.Stop()
for {
@@ -128,11 +127,13 @@ func (api *realtimeApi) connect(c echo.Context) error {
}
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
w := e.HttpContext.Response()
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
w.Write([]byte("id:" + e.Client.Id() + "\n"))
w.Write([]byte("event:" + e.Message.Name + "\n"))
w.Write([]byte("data:"))
w.Write(e.Message.Data)
w.Write([]byte("\n\n"))
w.Flush()
return nil
return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
})
if msgErr != nil {
if api.app.IsDebug() {
@@ -141,12 +142,8 @@ func (api *realtimeApi) connect(c echo.Context) error {
return nil
}
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
log.Println("OnRealtimeAfterMessageSend error:", err)
}
idleTimer.Stop()
idleTimer.Reset(idleDuration)
idleTimer.Reset(idleTimeout)
case <-c.Request().Context().Done():
// connection is closed
if api.app.IsDebug() {
@@ -190,7 +187,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
Subscriptions: form.Subscriptions,
}
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
return api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
// update auth state
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
@@ -201,21 +198,23 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// subscribe to the new subscriptions
e.Client.Subscribe(e.Subscriptions...)
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
api.app.OnRealtimeAfterSubscribeRequest().Trigger(event)
}
return handlerErr
}
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
if clientModel != nil &&
clientModel.TableName() == newModel.TableName() &&
clientModel.GetId() == newModel.GetId() {
client.Set(contextKey, newModel)
}
}
@@ -227,7 +226,9 @@ func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel model
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == model.GetId() {
if clientModel != nil &&
clientModel.TableName() == model.TableName() &&
clientModel.GetId() == model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
}
@@ -238,7 +239,7 @@ func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model mo
func (api *realtimeApi) bindEvents() {
// update the clients that has admin or auth record association
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
if record := api.resolveRecord(e.Model); record != nil && record.Collection().IsAuth() {
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
}
@@ -251,8 +252,8 @@ func (api *realtimeApi) bindEvents() {
// remove the client(s) associated to the deleted admin or auth record
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
if collection := api.resolveRecordCollection(e.Model); collection != nil && collection.IsAuth() {
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, e.Model)
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
@@ -263,8 +264,8 @@ func (api *realtimeApi) bindEvents() {
})
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("create", record, false); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
@@ -272,8 +273,8 @@ func (api *realtimeApi) bindEvents() {
})
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("update", record, false); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
@@ -281,8 +282,17 @@ func (api *realtimeApi) bindEvents() {
})
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastRecord("delete", record, true); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return nil
})
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
if record := api.resolveRecord(e.Model); record != nil {
if err := api.broadcastDryCachedRecord("delete", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
@@ -290,6 +300,33 @@ func (api *realtimeApi) bindEvents() {
})
}
// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecord(model models.Model) (record *models.Record) {
record, _ = model.(*models.Record)
// check if it is custom Record model struct (ignore "private" tables)
if record == nil && !strings.HasPrefix(model.TableName(), "_") {
record, _ = api.app.Dao().FindRecordById(model.TableName(), model.GetId())
}
return record
}
// resolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection *models.Collection) {
if record, ok := model.(*models.Record); ok {
collection = record.Collection()
} else if !strings.HasPrefix(model.TableName(), "_") {
// check if it is custom Record model struct (ignore "private" tables)
collection, _ = api.app.Dao().FindCollectionByNameOrId(model.TableName())
}
return collection
}
// canAccessRecord checks if the subscription client has access to the specified record model.
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
@@ -307,13 +344,13 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
return nil // empty public rule
}
// emulate request data
requestData := &models.RequestData{
// mock request data
requestInfo := &models.RequestInfo{
Method: "GET",
}
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
@@ -333,11 +370,11 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
}
type recordData struct {
Action string `json:"action"`
Record *models.Record `json:"record"`
Action string `json:"action"`
}
func (api *realtimeApi) broadcastRecord(action string, record *models.Record) error {
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
collection := record.Collection()
if collection == nil {
return errors.New("Record collection not set.")
@@ -369,14 +406,9 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
dataBytes, err := json.Marshal(data)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
}
return err
}
encodedData := string(dataBytes)
for _, client := range clients {
client := client
@@ -391,7 +423,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
msg := subscriptions.Message{
Name: subscription,
Data: encodedData,
Data: dataBytes,
}
// ignore the auth record email visibility checks for
@@ -402,23 +434,48 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
data.Record.IgnoreEmailVisibility(true) // ignore
if newData, err := json.Marshal(data); err == nil {
msg.Data = string(newData)
msg.Data = newData
}
data.Record.IgnoreEmailVisibility(false) // restore
}
}
routine.FireAndForget(func() {
if !client.IsDiscarded() {
client.Channel() <- msg
}
})
if dryCache {
client.Set(action+"/"+data.Record.Id, msg)
} else {
routine.FireAndForget(func() {
client.Send(msg)
})
}
}
}
return nil
}
// broadcastDryCachedRecord broadcasts record if it is cached in the client context.
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
clients := api.app.SubscriptionsBroker().Clients()
for _, client := range clients {
key := action + "/" + record.Id
msg, ok := client.Get(key).(subscriptions.Message)
if !ok {
continue
}
client.Unset(key)
client := client
routine.FireAndForget(func() {
client.Send(msg)
})
}
return nil
}
type getter interface {
Get(string) any
}
+119 -13
View File
@@ -7,8 +7,10 @@ import (
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
@@ -32,7 +34,7 @@ func TestRealtimeConnect(t *testing.T) {
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -56,7 +58,7 @@ func TestRealtimeConnect(t *testing.T) {
return nil
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -70,7 +72,6 @@ func TestRealtimeConnect(t *testing.T) {
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
@@ -78,7 +79,7 @@ func TestRealtimeConnect(t *testing.T) {
return hook.StopPropagation
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -123,7 +124,7 @@ func TestRealtimeSubscribe(t *testing.T) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if len(client.Subscriptions()) != 0 {
t.Errorf("Expected no subscriptions, got %v", client.Subscriptions())
}
@@ -144,7 +145,7 @@ func TestRealtimeSubscribe(t *testing.T) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
expectedSubs := []string{"test1", "test2"}
if len(expectedSubs) != len(client.Subscriptions()) {
t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions())
@@ -174,7 +175,7 @@ func TestRealtimeSubscribe(t *testing.T) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if admin == nil {
t.Errorf("Expected admin auth model, got nil")
@@ -198,7 +199,7 @@ func TestRealtimeSubscribe(t *testing.T) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
@@ -223,7 +224,7 @@ func TestRealtimeSubscribe(t *testing.T) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
@@ -253,7 +254,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = authRecord
testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
@@ -282,7 +286,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
}
authRecord2.SetEmail("new@example.com")
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = authRecord2
testApp.OnModelAfterUpdate().Trigger(e)
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != authRecord2.Email() {
@@ -305,7 +312,10 @@ func TestRealtimeAdminDeleteEvent(t *testing.T) {
client.Set(apis.ContextAdminKey, admin)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = admin
testApp.OnModelAfterDelete().Trigger(e)
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
@@ -334,10 +344,106 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
}
admin2.Email = "new@example.com"
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2})
e := new(core.ModelEvent)
e.Dao = testApp.Dao()
e.Model = admin2
testApp.OnModelAfterUpdate().Trigger(e)
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email {
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
}
}
// Custom auth record model struct
// -------------------------------------------------------------------
var _ models.Model = (*CustomUser)(nil)
type CustomUser struct {
models.BaseModel
Email string `db:"email" json:"email"`
}
func (m *CustomUser) TableName() string {
return "users" // the name of your collection
}
func findCustomUserByEmail(dao *daos.Dao, email string) (*CustomUser, error) {
model := &CustomUser{}
err := dao.ModelQuery(model).
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
if err != nil {
t.Fatal(err)
}
// delete the custom user (should unset the client auth record)
if err := testApp.Dao().Delete(customUser); err != nil {
t.Fatal(err)
}
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
}
}
func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord as CustomUser
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
if err != nil {
t.Fatal(err)
}
// change its email
customUser.Email = "new@example.com"
if err := testApp.Dao().Save(customUser); err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != customUser.Email {
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
}
}
+126 -86
View File
@@ -1,6 +1,7 @@
package apis
import (
"encoding/json"
"errors"
"fmt"
"log"
@@ -17,6 +18,7 @@ import (
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"golang.org/x/oauth2"
)
@@ -25,12 +27,15 @@ import (
func bindRecordAuthApi(app core.App, rg *echo.Group) {
api := recordAuthApi{app: app}
// global oauth2 subscription redirect handler
rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect)
// common collection record related routes
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app, models.CollectionTypeAuth),
)
subGroup.GET("/auth-methods", api.authMethods)
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
@@ -60,17 +65,11 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
event.Collection = record.Collection()
event.Record = record
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
return api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return api.app.OnRecordAfterAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
})
if handlerErr == nil {
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
type providerInfo struct {
@@ -134,9 +133,14 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
}
if name == auth.NameApple {
// custom providers url options
switch name {
case auth.NameApple:
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
case auth.NameVK:
// vk currently doesn't support PKCE for server-side authorization
urlOpts = []oauth2.AuthCodeOption{}
}
result.AuthProviders = append(result.AuthProviders, providerInfo{
@@ -185,9 +189,10 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := RequestData(c)
requestData.Data = form.CreateData
event.IsNewRecord = true
// clone the current request data and assign the form create data as its body data
requestInfo := *RequestInfo(c)
requestInfo.Data = form.CreateData
createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -200,7 +205,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
}
if *collection.CreateRule != "" {
resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestInfo, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -237,17 +242,21 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
e.Record = data.Record
e.OAuth2User = data.OAuth2User
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
meta := struct {
*auth.AuthUser
IsNew bool `json:"isNew"`
}{
AuthUser: e.OAuth2User,
IsNew: event.IsNewRecord,
}
return api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -277,17 +286,13 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
return NewBadRequestError("Failed to authenticate.", err)
}
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
return api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -327,25 +332,24 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
// eagerly write 204 response and skip submit errors
// as a measure against emails enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
return submitErr
}
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
@@ -372,17 +376,17 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -417,25 +421,24 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
// eagerly write 204 response and skip submit errors
// as a measure against users enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
return submitErr
}
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
@@ -462,17 +465,17 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -497,23 +500,23 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
event.Collection = collection
event.Record = record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to request email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
@@ -540,17 +543,17 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
return NewBadRequestError("Failed to confirm email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
@@ -614,17 +617,54 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
event.Record = record
event.ExternalAuth = externalAuth
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
return api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
return NewBadRequestError("Cannot unlink the external auth provider.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
return api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
if handlerErr == nil {
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
}
// -------------------------------------------------------------------
const oauth2SubscriptionTopic = "@oauth2"
func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
state := c.QueryParam("state")
code := c.QueryParam("code")
if code == "" || state == "" {
return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
}
return handlerErr
client, err := api.app.SubscriptionsBroker().ClientById(state)
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err)
}
data := map[string]string{
"state": state,
"code": code,
}
encodedData, err := json.Marshal(data)
if err != nil {
return NewBadRequestError("Failed to marshalize OAuth2 redirect data.", err)
}
msg := subscriptions.Message{
Name: oauth2SubscriptionTopic,
Data: encodedData,
}
client.Send(msg)
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
}
+255 -10
View File
@@ -1,14 +1,18 @@
package apis_test
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -278,6 +282,28 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnRecordAuthRequest": 1,
},
},
// after hooks error checks
{
Name: "OnRecordAfterAuthWithPasswordRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/auth-with-password",
Body: strings.NewReader(`{
"identity":"test2_username",
"password":"1234567890"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthWithPasswordRequest": 1,
"OnRecordAfterAuthWithPasswordRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -351,6 +377,25 @@ func TestRecordAuthRefresh(t *testing.T) {
"OnRecordAfterAuthRefreshRequest": 1,
},
},
{
Name: "OnRecordAfterAuthRefreshRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/auth-refresh?expand=rel,missing",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeAuthRefreshRequest": 1,
"OnRecordAfterAuthRefreshRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -492,10 +537,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
"password":"12345678",
"passwordConfirm":"12345678"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "different auth collection",
@@ -528,6 +571,29 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
},
{
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345678"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -669,10 +735,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "different auth collection",
@@ -730,6 +794,27 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
{
Name: "OnRecordAfterConfirmVerificationRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -930,6 +1015,28 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
`"token":{"code":"validation_token_collection_mismatch"`,
},
},
{
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-email-change",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
"password":"1234567890"
}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmEmailChangeRequest": 1,
"OnRecordAfterConfirmEmailChangeRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -1081,7 +1188,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
"OnRecordAfterUnlinkExternalAuthRequest": 1,
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
@@ -1127,7 +1234,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
"OnRecordAfterUnlinkExternalAuthRequest": 1,
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
@@ -1138,6 +1245,144 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
}
},
},
{
Name: "OnRecordBeforeUnlinkExternalAuthRequest error response",
Method: http.MethodDelete,
Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterUnlinkExternalAuthRequest": 1,
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRecordAuthOAuth2Redirect(t *testing.T) {
c1 := subscriptions.NewDefaultClient()
c2 := subscriptions.NewDefaultClient()
c2.Subscribe("@oauth2")
c3 := subscriptions.NewDefaultClient()
c3.Subscribe("test1", "@oauth2")
c4 := subscriptions.NewDefaultClient()
c4.Subscribe("test1", "test2")
c5 := subscriptions.NewDefaultClient()
c5.Subscribe("@oauth2")
c5.Discard()
beforeTestFunc := func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(c1)
app.SubscriptionsBroker().Register(c2)
app.SubscriptionsBroker().Register(c3)
app.SubscriptionsBroker().Register(c4)
app.SubscriptionsBroker().Register(c5)
}
scenarios := []tests.ApiScenario{
{
Name: "no state query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "no code query param",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?state=" + c3.Id(),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing client",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=missing",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "discarded client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "client without @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(),
BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "client with @oauth2 subscription",
Method: http.MethodGet,
Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
beforeTestFunc(t, app, e)
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
go func() {
defer cancelFunc()
L:
for {
select {
case <-c1.Channel():
t.Error("Unexpected c1 message")
break L
case <-c2.Channel():
t.Error("Unexpected c2 message")
break L
case msg := <-c3.Channel():
if msg.Name != "@oauth2" {
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
}
expectedParams := []string{`"state"`, `"code"`}
for _, p := range expectedParams {
if !strings.Contains(string(msg.Data), p) {
t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
}
}
break L
case <-c4.Channel():
t.Error("Unexpected c4 message")
break L
case <-c5.Channel():
t.Error("Unexpected c5 message")
break L
case <-ctx.Done():
t.Error("Context timeout reached")
break L
}
}
}()
},
ExpectedStatus: http.StatusTemporaryRedirect,
},
}
for _, scenario := range scenarios {
+62 -60
View File
@@ -50,9 +50,9 @@ func (api *recordApi) list(c echo.Context) error {
return err
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.ListRule == nil {
if requestInfo.Admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
@@ -60,15 +60,15 @@ func (api *recordApi) list(c echo.Context) error {
fieldsResolver := resolvers.NewRecordFieldResolver(
api.app.Dao(),
collection,
requestData,
requestInfo,
// hidden fields are searchable only by admins
requestData.Admin != nil,
requestInfo.Admin != nil,
)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection))
if requestData.Admin == nil && collection.ListRule != nil {
if requestInfo.Admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
@@ -86,6 +86,10 @@ func (api *recordApi) list(c echo.Context) error {
event.Result = result
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
log.Println(err)
}
@@ -105,16 +109,16 @@ func (api *recordApi) view(c echo.Context) error {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.ViewRule == nil {
if requestInfo.Admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
if requestInfo.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
@@ -136,6 +140,10 @@ func (api *recordApi) view(c echo.Context) error {
event.Record = record
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
@@ -150,23 +158,23 @@ func (api *recordApi) create(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.CreateRule == nil {
if requestInfo.Admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
hasFullManageAccess := requestData.Admin != nil
hasFullManageAccess := requestInfo.Admin != nil
// temporary save the record and check it against the create rule
if requestData.Admin == nil && collection.CreateRule != nil {
if requestInfo.Admin == nil && collection.CreateRule != nil {
testRecord := models.NewRecord(collection)
// replace modifiers fields so that the resolved value is always
// available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
// available when accessing requestInfo.Data using just the field name
if requestInfo.HasModifierDataKeys() {
requestInfo.Data = testRecord.ReplaceModifers(requestInfo.Data)
}
testForm := forms.NewRecordUpsert(api.app, testRecord)
@@ -180,7 +188,7 @@ func (api *recordApi) create(c echo.Context) error {
return nil // no create rule to resolve
}
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -195,7 +203,7 @@ func (api *recordApi) create(c echo.Context) error {
if err != nil {
return fmt.Errorf("DrySubmit create rule failure: %w", err)
}
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestInfo)
return nil
})
@@ -220,7 +228,7 @@ func (api *recordApi) create(c echo.Context) error {
event.UploadedFiles = form.FilesToUpload()
// create the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
@@ -233,18 +241,16 @@ func (api *recordApi) create(c echo.Context) error {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordApi) update(c echo.Context) error {
@@ -258,26 +264,26 @@ func (api *recordApi) update(c echo.Context) error {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.UpdateRule == nil {
if requestInfo.Admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
// eager fetch the record so that the modifier field values are replaced
// and available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
// and available when accessing requestInfo.Data using just the field name
if requestInfo.HasModifierDataKeys() {
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
requestData.Data = record.ReplaceModifers(requestData.Data)
requestInfo.Data = record.ReplaceModifers(requestInfo.Data)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
if requestInfo.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -295,7 +301,7 @@ func (api *recordApi) update(c echo.Context) error {
}
form := forms.NewRecordUpsert(api.app, record)
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
form.SetFullManageAccess(requestInfo.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestInfo))
// load request
if err := form.LoadRequest(c.Request(), ""); err != nil {
@@ -309,7 +315,7 @@ func (api *recordApi) update(c echo.Context) error {
event.UploadedFiles = form.FilesToUpload()
// update the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
@@ -322,18 +328,16 @@ func (api *recordApi) update(c echo.Context) error {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordApi) delete(c echo.Context) error {
@@ -347,16 +351,16 @@ func (api *recordApi) delete(c echo.Context) error {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if requestData.Admin == nil && collection.DeleteRule == nil {
if requestInfo.Admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
if requestInfo.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
@@ -377,22 +381,20 @@ func (api *recordApi) delete(c echo.Context) error {
event.Collection = collection
event.Record = record
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
return api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
// delete the record
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
return api.app.OnRecordAfterDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
})
if handlerErr == nil {
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
+124 -5
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"net/url"
"os"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -460,6 +462,22 @@ func TestRecordCrudList(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "view collection with numeric ids",
Method: http.MethodGet,
Url: "/api/collections/numeric_id_view/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":1`,
`"totalItems":2`,
`"items":[{`,
`"id":"1"`,
`"id":"2"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -729,6 +747,16 @@ func TestRecordCrudView(t *testing.T) {
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "view record with numeric id",
Method: http.MethodGet,
Url: "/api/collections/numeric_id_view/records/1",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"1"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
}
for _, scenario := range scenarios {
@@ -836,6 +864,27 @@ func TestRecordCrudDelete(t *testing.T) {
"OnRecordBeforeDeleteRequest": 1,
},
},
{
Name: "OnRecordAfterDeleteRequest error response",
Method: http.MethodDelete,
Url: "/api/collections/clients/records/o1y0dd0spd786md",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelAfterDelete": 1,
"OnModelBeforeDelete": 1,
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
},
{
Name: "authenticated record that match the collection delete rule",
Method: http.MethodDelete,
@@ -854,7 +903,7 @@ func TestRecordCrudDelete(t *testing.T) {
"OnRecordAfterDeleteRequest": 1,
"OnRecordBeforeDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "_pb_users_auth_", "4q1xlclmfloku33")
// check if all the external auths records were deleted
@@ -941,7 +990,7 @@ func TestRecordCrudDelete(t *testing.T) {
"OnRecordBeforeDeleteRequest": 1,
"OnRecordAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
recId := "84nmscqy84lsi1t"
rec, _ := app.Dao().FindRecordById("demo1", recId, nil)
if rec != nil {
@@ -1173,6 +1222,39 @@ func TestRecordCrudCreate(t *testing.T) {
"OnModelAfterCreate": 1,
},
},
{
Name: "unique field error check",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"title":"test2"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"title":{`,
`"code":"validation_not_unique"`,
},
},
{
Name: "OnRecordAfterCreateRequest error response",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{"title":"new"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
// ID checks
// -----------------------------------------------------------
@@ -1731,6 +1813,25 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnModelAfterUpdate": 1,
},
},
{
Name: "OnRecordAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{"title":"new"}`),
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "try to change the id of an existing record",
Method: http.MethodPatch,
@@ -1747,6 +1848,24 @@ func TestRecordCrudUpdate(t *testing.T) {
`"id":{"code":"validation_in_invalid"`,
},
},
{
Name: "unique field error check",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/llvuca81nly1qls",
Body: strings.NewReader(`{
"title":"test2"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"title":{`,
`"code":"validation_not_unique"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
},
},
// check whether if @request.data modifer fields are properly resolved
// -----------------------------------------------------------
@@ -1913,7 +2032,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, _ := app.Dao().FindRecordById("nologin", "phhq3wr65cap535")
if !record.ValidatePassword("12345678") {
t.Fatal("Password update failed.")
@@ -1956,7 +2075,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
if !record.ValidatePassword("12345678") {
t.Fatal("Password update failed.")
@@ -2018,7 +2137,7 @@ func TestRecordCrudUpdate(t *testing.T) {
"OnRecordAfterUpdateRequest": 1,
"OnRecordBeforeUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
record, _ := app.Dao().FindRecordById("nologin", "dc49k6jgejn40h3")
if !record.ValidatePassword("123456789") {
t.Fatal("Password update failed.")
+57 -28
View File
@@ -17,19 +17,28 @@ import (
"github.com/pocketbase/pocketbase/tools/search"
)
const ContextRequestDataKey = "requestData"
const ContextRequestInfoKey = "requestInfo"
// RequestData exports cached common request data fields
// Deprecated: Use RequestInfo instead.
func RequestData(c echo.Context) *models.RequestInfo {
log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
return RequestInfo(c)
}
// RequestInfo exports cached common request data fields
// (query, body, logged auth state, etc.) from the provided context.
func RequestData(c echo.Context) *models.RequestData {
func RequestInfo(c echo.Context) *models.RequestInfo {
// return cached to avoid copying the body multiple times
if v := c.Get(ContextRequestDataKey); v != nil {
if data, ok := v.(*models.RequestData); ok {
if v := c.Get(ContextRequestInfoKey); v != nil {
if data, ok := v.(*models.RequestInfo); ok {
// refresh auth state
data.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
data.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
return data
}
}
result := &models.RequestData{
result := &models.RequestInfo{
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
@@ -49,12 +58,20 @@ func RequestData(c echo.Context) *models.RequestData {
echo.BindQueryParams(c, &result.Query)
rest.BindBody(c, &result.Data)
c.Set(ContextRequestDataKey, result)
c.Set(ContextRequestInfoKey, result)
return result
}
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
// RecordAuthResponse writes standardised json record auth response
// into the specified request context.
func RecordAuthResponse(
app core.App,
c echo.Context,
authRecord *models.Record,
meta any,
finalizers ...func(token string) error,
) error {
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
@@ -68,6 +85,10 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
event.Meta = meta
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
@@ -75,13 +96,13 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
// create a copy of the cached request data and adjust it to the current auth record
requestData := *RequestData(e.HttpContext)
requestData.Admin = nil
requestData.AuthRecord = e.Record
requestInfo := *RequestInfo(e.HttpContext)
requestInfo.Admin = nil
requestInfo.AuthRecord = e.Record
failed := app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(app.Dao(), &requestData),
expandFetch(app.Dao(), &requestInfo),
)
if len(failed) > 0 && app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
@@ -97,6 +118,12 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
result["meta"] = e.Meta
}
for _, f := range finalizers {
if err := f(e.Token); err != nil {
return err
}
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
@@ -114,19 +141,21 @@ func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultE
// - ensures that the emails of the auth records and their expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
requestData := RequestData(c)
requestInfo := RequestInfo(c)
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
return fmt.Errorf("Failed to resolve email visibility: %w", err)
}
expands := defaultExpands
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
if param := c.QueryParam(expandQueryParam); param != "" {
expands = append(expands, strings.Split(param, ",")...)
}
if len(expands) == 0 {
return nil // nothing to expand
}
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
if len(errs) > 0 {
return fmt.Errorf("Failed to expand: %v", errs)
}
@@ -137,11 +166,11 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
// expandFetch is the records fetch function that is used to expand related records.
func expandFetch(
dao *daos.Dao,
requestData *models.RequestData,
requestInfo *models.RequestInfo,
) daos.ExpandFetchFunc {
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
if requestData.Admin != nil {
if requestInfo.Admin != nil {
return nil // admins can access everything
}
@@ -150,7 +179,7 @@ func expandFetch(
}
if *relCollection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestInfo, true)
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
@@ -163,7 +192,7 @@ func expandFetch(
})
if err == nil && len(records) > 0 {
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
}
return records, err
@@ -177,13 +206,13 @@ func expandFetch(
func autoIgnoreAuthRecordsEmailVisibility(
dao *daos.Dao,
records []*models.Record,
requestData *models.RequestData,
requestInfo *models.RequestInfo,
) error {
if len(records) == 0 || !records[0].Collection().IsAuth() {
return nil // nothing to check
}
if requestData.Admin != nil {
if requestInfo.Admin != nil {
for _, rec := range records {
rec.IgnoreEmailVisibility(true)
}
@@ -199,8 +228,8 @@ func autoIgnoreAuthRecordsEmailVisibility(
recordIds[i] = rec.Id
}
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
if requestInfo != nil && requestInfo.AuthRecord != nil && mappedRecords[requestInfo.AuthRecord.Id] != nil {
mappedRecords[requestInfo.AuthRecord.Id].IgnoreEmailVisibility(true)
}
authOptions := collection.AuthOptions()
@@ -216,7 +245,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestInfo, true)
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
if err != nil {
return err
@@ -245,7 +274,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
func hasAuthManageAccess(
dao *daos.Dao,
record *models.Record,
requestData *models.RequestData,
requestInfo *models.RequestInfo,
) bool {
if !record.Collection().IsAuth() {
return false
@@ -257,12 +286,12 @@ func hasAuthManageAccess(
return false // only for admins (manageRule can't be empty)
}
if requestData == nil || requestData.AuthRecord == nil {
if requestInfo == nil || requestInfo.AuthRecord == nil {
return false // no auth record
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
return err
+3 -3
View File
@@ -13,7 +13,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestRequestData(t *testing.T) {
func TestRequestInfo(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
@@ -29,10 +29,10 @@ func TestRequestData(t *testing.T) {
dummyAdmin.Id = "id2"
c.Set(apis.ContextAdminKey, dummyAdmin)
result := apis.RequestData(c)
result := apis.RequestInfo(c)
if result == nil {
t.Fatal("Expected *models.RequestData instance, got nil")
t.Fatal("Expected *models.RequestInfo instance, got nil")
}
if result.Method != http.MethodPost {
+243
View File
@@ -0,0 +1,243 @@
package apis
import (
"context"
"crypto/tls"
"log"
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/migrate"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
// ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
ShowStartBanner bool
// HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`).
HttpAddr string
// HttpsAddr is the TCP address to listen for the HTTPS server (eg. `127.0.0.1:443`).
HttpsAddr string
// Optional domains list to use when issuing the TLS certificate.
//
// If not set, the host from the bound server address will be used.
//
// For convenience, for each "non-www" domain a "www" entry and
// redirect will be automatically added.
CertificateDomains []string
// AllowedOrigins is an optional list of CORS origins (default to "*").
AllowedOrigins []string
}
// Serve starts a new app web server.
//
// NB! The app should be bootstrapped before starting the web server.
//
// Example:
//
// app.Bootstrap()
// apis.Serve(app, apis.ServeConfig{
// HttpAddr: "127.0.0.1:8080",
// ShowStartBanner: false,
// })
func Serve(app core.App, config ServeConfig) (*http.Server, error) {
if len(config.AllowedOrigins) == 0 {
config.AllowedOrigins = []string{"*"}
}
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
return nil, err
}
// reload app settings in case a new default value was set with a migration
// (or if this is the first time the init migration was executed)
if err := app.RefreshSettings(); err != nil {
color.Yellow("=====================================")
color.Yellow("WARNING: Settings load error! \n%v", err)
color.Yellow("Fallback to the application defaults.")
color.Yellow("=====================================")
}
router, err := InitApi(app)
if err != nil {
return nil, err
}
// configure cors
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
Skipper: middleware.DefaultSkipper,
AllowOrigins: config.AllowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
// start http server
// ---
mainAddr := config.HttpAddr
if config.HttpsAddr != "" {
mainAddr = config.HttpsAddr
}
var wwwRedirects []string
// extract the host names for the certificate host policy
hostNames := config.CertificateDomains
if len(hostNames) == 0 {
host, _, _ := net.SplitHostPort(mainAddr)
hostNames = append(hostNames, host)
}
for _, host := range hostNames {
if strings.HasPrefix(host, "www.") {
continue // explicitly set www host
}
wwwHost := "www." + host
if !list.ExistInSlice(wwwHost, hostNames) {
hostNames = append(hostNames, wwwHost)
wwwRedirects = append(wwwRedirects, wwwHost)
}
}
// implicit www->non-www redirect(s)
if len(wwwRedirects) > 0 {
router.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
host := c.Request().Host
if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, wwwRedirects) {
return c.Redirect(
http.StatusTemporaryRedirect,
(c.Scheme() + "://" + host[4:] + c.Request().RequestURI),
)
}
return next(c)
}
})
}
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
HostPolicy: autocert.HostWhitelist(hostNames...),
}
server := &http.Server{
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
ReadTimeout: 10 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Addr: mainAddr,
}
serveEvent := &core.ServeEvent{
App: app,
Router: router,
Server: server,
CertManager: certManager,
}
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
return nil, err
}
if config.ShowStartBanner {
schema := "http"
addr := server.Addr
if config.HttpsAddr != "" {
schema = "https"
if len(config.CertificateDomains) > 0 {
addr = config.CertificateDomains[0]
}
}
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, addr),
)
regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
}
// try to gracefully shutdown the server on app termination
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
server.Shutdown(ctx)
return nil
})
// start HTTPS server
if config.HttpsAddr != "" {
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
if config.HttpAddr != "" {
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
}
return server, server.ListenAndServeTLS("", "")
}
// OR start HTTP server
return server, server.ListenAndServe()
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func runMigrations(app core.App) error {
connections := []migrationsConnection{
{
DB: app.DB(),
MigrationsList: migrations.AppMigrations,
},
{
DB: app.LogsDB(),
MigrationsList: logs.LogsMigrations,
},
}
for _, c := range connections {
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
if err != nil {
return err
}
if _, err := runner.Up(); err != nil {
return err
}
}
return nil
}
+29 -35
View File
@@ -1,8 +1,6 @@
package apis
import (
"fmt"
"log"
"net/http"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -10,7 +8,6 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/security"
)
// bindSettingsApi registers the settings api endpoints.
@@ -40,6 +37,10 @@ func (api *settingsApi) list(c echo.Context) error {
event.RedactedSettings = settings
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
})
}
@@ -57,7 +58,7 @@ func (api *settingsApi) set(c echo.Context) error {
event.OldSettings = api.app.Settings()
// update the settings
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func(s *settings.Settings) error {
event.NewSettings = s
@@ -66,47 +67,40 @@ func (api *settingsApi) set(c echo.Context) error {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return NewBadRequestError("", err)
}
return api.app.OnSettingsAfterUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if e.HttpContext.Response().Committed {
return nil
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return NewBadRequestError("", err)
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
})
})
}
})
if submitErr == nil {
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *settingsApi) testS3(c echo.Context) error {
if !api.app.Settings().S3.Enabled {
return NewBadRequestError("S3 storage is not enabled.", nil)
form := forms.NewTestS3Filesystem(api.app)
// load request
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
fs, err := api.app.NewFilesystem()
if err != nil {
return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
}
defer fs.Close()
// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
return NewBadRequestError("Failed to test the S3 filesystem.", fErr)
}
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
testFileKey := testPrefix + "/test.txt"
// try to upload a test file
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
// test prefix deletion (ensures that both bucket list and delete works)
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
// mailer error
return NewBadRequestError("Failed to test the S3 filesystem. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
+84 -6
View File
@@ -6,12 +6,14 @@ import (
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
@@ -47,12 +49,15 @@ func TestSettingsList(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
`"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -72,6 +77,11 @@ func TestSettingsList(t *testing.T) {
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -123,12 +133,15 @@ func TestSettingsSet(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
`"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -147,6 +160,11 @@ func TestSettingsSet(t *testing.T) {
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -186,12 +204,15 @@ func TestSettingsSet(t *testing.T) {
`"logs":{`,
`"smtp":{`,
`"s3":{`,
`"backups":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"adminFileToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"recordFileToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -211,6 +232,11 @@ func TestSettingsSet(t *testing.T) {
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"instagramAuth":{`,
`"vkAuth":{`,
`"yandexAuth":{`,
`"patreonAuth":{`,
`"mailcowAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -222,6 +248,28 @@ func TestSettingsSet(t *testing.T) {
"OnSettingsAfterUpdateRequest": 1,
},
},
{
Name: "OnSettingsAfterUpdateRequest error response",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnSettingsAfterUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error {
return errors.New("error")
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnSettingsBeforeUpdateRequest": 1,
"OnSettingsAfterUpdateRequest": 1,
},
},
}
for _, scenario := range scenarios {
@@ -249,14 +297,44 @@ func TestSettingsTestS3(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (no s3)",
Name: "authorized as admin (missing body + no s3)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"filesystem":{`,
},
},
{
Name: "authorized as admin (invalid filesystem)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"invalid"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"filesystem":{`,
},
},
{
Name: "authorized as admin (valid filesystem and no s3)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"storage"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
},
}
@@ -328,7 +406,7 @@ func TestSettingsTestEmail(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
@@ -363,7 +441,7 @@ func TestSettingsTestEmail(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
@@ -398,7 +476,7 @@ func TestSettingsTestEmail(t *testing.T) {
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
+135
View File
@@ -0,0 +1,135 @@
package cmd
import (
"errors"
"fmt"
"github.com/fatih/color"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/spf13/cobra"
)
// NewAdminCommand creates and returns new command for managing
// admin accounts (create, update, delete).
func NewAdminCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "admin",
Short: "Manages admin accounts",
}
command.AddCommand(adminCreateCommand(app))
command.AddCommand(adminUpdateCommand(app))
command.AddCommand(adminDeleteCommand(app))
return command
}
func adminCreateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "create",
Example: "admin create test@example.com 1234567890",
Short: "Creates a new admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
}
if len(args[1]) < 8 {
return errors.New("The password must be at least 8 chars long.")
}
admin := &models.Admin{}
admin.Email = args[0]
admin.SetPassword(args[1])
if err := app.Dao().SaveAdmin(admin); err != nil {
return fmt.Errorf("Failed to create new admin account: %v", err)
}
color.Green("Successfully created new admin %s!", admin.Email)
return nil
},
}
return command
}
func adminUpdateCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "update",
Example: "admin update test@example.com 1234567890",
Short: "Changes the password of a single admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
}
if len(args[1]) < 8 {
return errors.New("The new password must be at least 8 chars long.")
}
admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil {
return fmt.Errorf("Admin with email %s doesn't exist.", args[0])
}
admin.SetPassword(args[1])
if err := app.Dao().SaveAdmin(admin); err != nil {
return fmt.Errorf("Failed to change admin %s password: %v", admin.Email, err)
}
color.Green("Successfully changed admin %s password!", admin.Email)
return nil
},
}
return command
}
func adminDeleteCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "delete",
Example: "admin delete test@example.com",
Short: "Deletes an existing admin account",
// prevents printing the error log twice
SilenceErrors: true,
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.")
}
admin, err := app.Dao().FindAdminByEmail(args[0])
if err != nil {
color.Yellow("Admin %s is already deleted.", args[0])
return nil
}
if err := app.Dao().DeleteAdmin(admin); err != nil {
return fmt.Errorf("Failed to delete admin %s: %v", admin.Email, err)
}
color.Green("Successfully deleted admin %s!", admin.Email)
return nil
},
}
return command
}
+219
View File
@@ -0,0 +1,219 @@
package cmd_test
import (
"testing"
"github.com/pocketbase/pocketbase/cmd"
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminCreateCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
email string
password string
expectError bool
}{
{
"empty email and password",
"",
"",
true,
},
{
"empty email",
"",
"1234567890",
true,
},
{
"invalid email",
"invalid",
"1234567890",
true,
},
{
"duplicated email",
"test@example.com",
"1234567890",
true,
},
{
"empty password",
"test@example.com",
"",
true,
},
{
"short password",
"test_new@example.com",
"1234567",
true,
},
{
"valid email and password",
"test_new@example.com",
"12345678",
false,
},
}
for _, s := range scenarios {
command := cmd.NewAdminCommand(app)
command.SetArgs([]string{"create", s.email, s.password})
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
// check whether the admin account was actually created
admin, err := app.Dao().FindAdminByEmail(s.email)
if err != nil {
t.Errorf("[%s] Failed to fetch created admin %s: %v", s.name, s.email, err)
} else if !admin.ValidatePassword(s.password) {
t.Errorf("[%s] Expected the admin password to match", s.name)
}
}
}
func TestAdminUpdateCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
email string
password string
expectError bool
}{
{
"empty email and password",
"",
"",
true,
},
{
"empty email",
"",
"1234567890",
true,
},
{
"invalid email",
"invalid",
"1234567890",
true,
},
{
"nonexisting admin",
"test_missing@example.com",
"1234567890",
true,
},
{
"empty password",
"test@example.com",
"",
true,
},
{
"short password",
"test_new@example.com",
"1234567",
true,
},
{
"valid email and password",
"test@example.com",
"12345678",
false,
},
}
for _, s := range scenarios {
command := cmd.NewAdminCommand(app)
command.SetArgs([]string{"update", s.email, s.password})
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
// check whether the admin password was actually changed
admin, err := app.Dao().FindAdminByEmail(s.email)
if err != nil {
t.Errorf("[%s] Failed to fetch admin %s: %v", s.name, s.email, err)
} else if !admin.ValidatePassword(s.password) {
t.Errorf("[%s] Expected the admin password to match", s.name)
}
}
}
func TestAdminDeleteCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
email string
expectError bool
}{
{
"empty email",
"",
true,
},
{
"invalid email",
"invalid",
true,
},
{
"nonexisting admin",
"test_missing@example.com",
false,
},
{
"existing admin",
"test@example.com",
false,
},
}
for _, s := range scenarios {
command := cmd.NewAdminCommand(app)
command.SetArgs([]string{"delete", s.email})
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
// check whether the admin account was actually deleted
if _, err := app.Dao().FindAdminByEmail(s.email); err == nil {
t.Errorf("[%s] Expected the admin account to be deleted", s.name)
}
}
}
+25 -134
View File
@@ -1,25 +1,12 @@
package cmd
import (
"crypto/tls"
"log"
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
// NewServeCommand creates and returns new command responsible for
@@ -30,99 +17,34 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
var httpsAddr string
command := &cobra.Command{
Use: "serve",
Short: "Starts the web server (default to 127.0.0.1:8090)",
Use: "serve [domain(s)]",
Args: cobra.ArbitraryArgs,
Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
Run: func(command *cobra.Command, args []string) {
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
panic(err)
}
// reload app settings in case a new default value was set with a migration
// (or if this is the first time the init migration was executed)
if err := app.RefreshSettings(); err != nil {
color.Yellow("=====================================")
color.Yellow("WARNING: Settings load error! \n%v", err)
color.Yellow("Fallback to the application defaults.")
color.Yellow("=====================================")
}
router, err := apis.InitApi(app)
if err != nil {
panic(err)
}
// configure cors
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
Skipper: middleware.DefaultSkipper,
AllowOrigins: allowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
// start http server
// ---
mainAddr := httpAddr
if httpsAddr != "" {
mainAddr = httpsAddr
}
mainHost, _, _ := net.SplitHostPort(mainAddr)
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
}
serverConfig := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Addr: mainAddr,
}
if showStartBanner {
schema := "http"
if httpsAddr != "" {
schema = "https"
// set default listener addresses if at least one domain is specified
if len(args) > 0 {
if httpAddr == "" {
httpAddr = "0.0.0.0:80"
}
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, serverConfig.Addr),
)
regular := color.New()
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
}
var serveErr error
if httpsAddr != "" {
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
if httpAddr != "" {
go http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil))
if httpsAddr == "" {
httpsAddr = "0.0.0.0:443"
}
// start HTTPS server
serveErr = serverConfig.ListenAndServeTLS("", "")
} else {
// start HTTP server
serveErr = serverConfig.ListenAndServe()
if httpAddr == "" {
httpAddr = "127.0.0.1:8090"
}
}
if serveErr != http.ErrServerClosed {
log.Fatalln(serveErr)
_, err := apis.Serve(app, apis.ServeConfig{
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
ShowStartBanner: showStartBanner,
AllowedOrigins: allowedOrigins,
CertificateDomains: args,
})
if err != http.ErrServerClosed {
log.Fatalln(err)
}
},
}
@@ -137,47 +59,16 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command.PersistentFlags().StringVar(
&httpAddr,
"http",
"127.0.0.1:8090",
"api HTTP server address",
"",
"TCP address to listen for the HTTP server\n(if domain args are specified - default to 0.0.0.0:80, otherwise - default to 127.0.0.1:8090)",
)
command.PersistentFlags().StringVar(
&httpsAddr,
"https",
"",
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incoming --http address traffic also will be redirected to this address",
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
)
return command
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func runMigrations(app core.App) error {
connections := []migrationsConnection{
{
DB: app.DB(),
MigrationsList: migrations.AppMigrations,
},
{
DB: app.LogsDB(),
MigrationsList: logs.LogsMigrations,
},
}
for _, c := range connections {
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
if err != nil {
return err
}
if _, err := runner.Up(); err != nil {
return err
}
}
return nil
}
-444
View File
@@ -1,444 +0,0 @@
package cmd
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cobra"
)
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
//
// NB! It will be removed in v0.9+
func NewTempUpgradeCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "upgrade",
Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
Long: `
Upgrades your existing pb_data to be compatible with the v0.8.x changes
Prerequisites and caveats:
- already upgraded to v0.7.*
- no existing users collection
- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
`,
Run: func(command *cobra.Command, args []string) {
if err := upgrade(app); err != nil {
color.Red("Error: %v", err)
}
},
}
return command
}
func upgrade(app core.App) error {
if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
}
return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
if err := migrateCollections(txDao); err != nil {
return err
}
if err := migrateUsers(app, txDao); err != nil {
return err
}
if err := resetMigrationsTable(txDao); err != nil {
return err
}
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Println("The pb_data upgrade completed successfully!")
bold.Println("You can now start the application as usual with the 'serve' command.")
bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
fmt.Println()
return nil
})
}
// -------------------------------------------------------------------
func migrateCollections(txDao *daos.Dao) error {
// add new collection columns
if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
return err
}
if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
return err
}
ruleReplacements := []struct {
old string
new string
}{
{"expand", "expand2"},
{"collecitonId", "collectionId2"},
{"collecitonName", "collectionName2"},
{"profile.userId", "profile.id"},
// @collection.*
{"@collection.profiles.userId", "@collection.users.id"},
{"@collection.profiles.username", "@collection.users.username2"},
{"@collection.profiles.email", "@collection.users.email2"},
{"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
{"@collection.profiles.verified", "@collection.users.verified2"},
{"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
{"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
{"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
{"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
{"@collection.profiles.", "@collection.users."},
// @request.*
{"@request.user.profile.userId", "@request.auth.id"},
{"@request.user.profile.username", "@request.auth.username2"},
{"@request.user.profile.email", "@request.auth.email2"},
{"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
{"@request.user.profile.verified", "@request.auth.verified2"},
{"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
{"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
{"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
{"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
{"@request.user.profile.", "@request.auth."},
{"@request.user", "@request.auth"},
}
collections := []*models.Collection{}
if err := txDao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, collection := range collections {
collection.Type = models.CollectionTypeBase
collection.NormalizeOptions()
// rename profile fields
// ---
fieldsToRename := []string{
"collectionId",
"collectionName",
"expand",
}
if collection.Name == "profiles" {
fieldsToRename = append(fieldsToRename,
"username",
"email",
"emailVisibility",
"verified",
"tokenKey",
"passwordHash",
"lastResetSentAt",
"lastVerificationSentAt",
)
}
for _, name := range fieldsToRename {
f := collection.Schema.GetFieldByName(name)
if f != nil {
color.Blue("[%s - renamed field]", collection.Name)
color.Yellow(" - old: %s", f.Name)
color.Green(" - new: %s2", f.Name)
fmt.Println()
f.Name += "2"
}
}
// ---
// replace rule fields
// ---
rules := map[string]*string{
"ListRule": collection.ListRule,
"ViewRule": collection.ViewRule,
"CreateRule": collection.CreateRule,
"UpdateRule": collection.UpdateRule,
"DeleteRule": collection.DeleteRule,
}
for ruleKey, rule := range rules {
if rule == nil || *rule == "" {
continue
}
originalRule := *rule
for _, replacement := range ruleReplacements {
re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
*rule = re.ReplaceAllString(*rule, replacement.new)
}
*rule = replaceReversedLikes(*rule)
if originalRule != *rule {
color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
color.Green(" - new: %s", strings.TrimSpace(*rule))
fmt.Println()
}
}
// ---
if err := txDao.SaveCollection(collection); err != nil {
return err
}
}
return nil
}
func migrateUsers(app core.App, txDao *daos.Dao) error {
color.Blue(`[merging "_users" and "profiles"]:`)
profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
if err != nil {
return err
}
originalProfilesCollectionId := profilesCollection.Id
// change the profiles collection id to something else since we will be using
// it for the new users collection in order to avoid renaming the storage dir
_, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{_collections}}
SET id = '%s'
WHERE id = '%s';
`,
(originalProfilesCollectionId + "__old__"),
originalProfilesCollectionId,
)).Execute()
if idRenameErr != nil {
return idRenameErr
}
// refresh profiles collection
profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
if err != nil {
return err
}
usersSchema, _ := profilesCollection.Schema.Clone()
userIdField := usersSchema.GetFieldByName("userId")
if userIdField != nil {
usersSchema.RemoveField(userIdField.Id)
}
usersCollection := &models.Collection{}
usersCollection.MarkAsNew()
usersCollection.Id = originalProfilesCollectionId
usersCollection.Name = "users"
usersCollection.Type = models.CollectionTypeAuth
usersCollection.Schema = *usersSchema
usersCollection.CreateRule = types.Pointer("")
if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
*profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
usersCollection.ListRule = profilesCollection.ListRule
}
if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
*profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
usersCollection.ViewRule = profilesCollection.ViewRule
}
if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
*profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
usersCollection.UpdateRule = profilesCollection.UpdateRule
}
if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
*profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
usersCollection.DeleteRule = profilesCollection.DeleteRule
}
// set auth options
settings := app.Settings()
authOptions := usersCollection.AuthOptions()
authOptions.ManageRule = nil
authOptions.AllowOAuth2Auth = true
authOptions.AllowUsernameAuth = false
authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
// twitter currently is the only provider that doesn't return an email
authOptions.RequireEmail = !settings.TwitterAuth.Enabled
usersCollection.SetOptions(authOptions)
if err := txDao.SaveCollection(usersCollection); err != nil {
return err
}
// copy the original users
_, usersErr := txDao.DB().NewQuery(`
INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
FROM {{_users}};
`).Execute()
if usersErr != nil {
return usersErr
}
// generate the profile fields copy statements
sets := []string{"id = p.id"}
for _, f := range usersSchema.Fields() {
sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
}
// copy profile fields
_, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
UPDATE {{users}} as u
SET %s
FROM {{profiles}} as p
WHERE u.id = p.userId;
`, strings.Join(sets, ", "))).Execute()
if copyProfileErr != nil {
return copyProfileErr
}
profileRecords, err := txDao.FindRecordsByExpr("profiles")
if err != nil {
return err
}
// update all profiles and users fields to point to the new users collection
collections := []*models.Collection{}
if err := txDao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, collection := range collections {
var hasChanges bool
for _, f := range collection.Schema.Fields() {
f.InitOptions()
if f.Type == schema.FieldTypeUser {
if collection.Name == "profiles" && f.Name == "userId" {
continue
}
hasChanges = true
// change the user field to a relation field
options, _ := f.Options.(*schema.UserOptions)
f.Type = schema.FieldTypeRelation
f.Options = &schema.RelationOptions{
CollectionId: usersCollection.Id,
MaxSelect: &options.MaxSelect,
CascadeDelete: options.CascadeDelete,
}
for _, p := range profileRecords {
pId := p.Id
pUserId := p.GetString("userId")
// replace all user record id references with the profile id
_, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
UPDATE %s
SET [[%s]] = REPLACE([[%s]], '%s', '%s')
WHERE [[%s]] LIKE ('%%%s%%');
`, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
if replaceErr != nil {
return replaceErr
}
}
}
}
if hasChanges {
if err := txDao.Save(collection); err != nil {
return err
}
}
}
if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
return err
}
// drop _users table
if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
return err
}
// drop profiles table
if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
return err
}
// delete profiles collection
if err := txDao.Delete(profilesCollection); err != nil {
return err
}
color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
fmt.Println()
return nil
}
func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
_, alterErr := txDao.DB().NewQuery(`
-- crate new externalAuths table
CREATE TABLE {{_newExternalAuths}} (
[[id]] TEXT PRIMARY KEY,
[[collectionId]] TEXT NOT NULL,
[[recordId]] TEXT NOT NULL,
[[provider]] TEXT NOT NULL,
[[providerId]] TEXT NOT NULL,
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL,
---
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
);
-- copy all data from the old table to the new one
INSERT INTO {{_newExternalAuths}}
SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
FROM {{_externalAuths}} auth
INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
-- drop old table
DROP TABLE {{_externalAuths}};
-- rename new table
ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
-- create named indexes
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
`).Execute()
return alterErr
}
func resetMigrationsTable(txDao *daos.Dao) error {
// reset the migration state to the new init
_, err := txDao.DB().Delete("_migrations", dbx.HashExp{
"file": "1661586591_add_externalAuths_table.go",
}).Execute()
return err
}
var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
func replaceReversedLikes(rule string) string {
parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
for _, p := range parts {
if len(p) != 4 {
continue
}
newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
rule = strings.ReplaceAll(rule, p[0], newPart)
}
return rule
}
+134 -85
View File
@@ -4,6 +4,8 @@
package core
import (
"context"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/settings"
@@ -69,12 +71,20 @@ type App interface {
// NewMailClient creates and returns a configured app mail client.
NewMailClient() mailer.Mailer
// NewFilesystem creates and returns a configured filesystem.System instance.
// NewFilesystem creates and returns a configured filesystem.System instance
// for managing regular app files (eg. collection uploads).
//
// NB! Make sure to call `Close()` on the returned result
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
NewFilesystem() (*filesystem.System, error)
// NewBackupsFilesystem creates and returns a configured filesystem.System instance
// for managing app backups.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
NewBackupsFilesystem() (*filesystem.System, error)
// RefreshSettings reinitializes and reloads the stored application settings.
RefreshSettings() error
@@ -92,25 +102,50 @@ type App interface {
// (eg. closing db connections).
ResetBootstrapState() error
// CreateBackup creates a new backup of the current app pb_data directory.
//
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
//
// Please refer to the godoc of the specific core.App implementation
// for details on the backup procedures.
CreateBackup(ctx context.Context, name string) error
// RestoreBackup restores the backup with the specified name and restarts
// the current running application process.
//
// The safely perform the restore it is recommended to have free disk space
// for at least 2x the size of the restored pb_data backup.
//
// Please refer to the godoc of the specific core.App implementation
// for details on the restore procedures.
//
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
RestoreBackup(ctx context.Context, name string) error
// Restart restarts the current running application process.
//
// Currently it is relying on execve so it is supported only on UNIX based systems.
Restart() error
// ---------------------------------------------------------------
// App event hooks
// ---------------------------------------------------------------
// OnBeforeBootstrap hook is triggered before initializing the base
// OnBeforeBootstrap hook is triggered before initializing the main
// application resources (eg. before db open and initial settings load).
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
// OnAfterBootstrap hook is triggered after initializing the base
// OnAfterBootstrap hook is triggered after initializing the main
// application resources (eg. after db open and initial settings load).
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
// OnBeforeServe hook is triggered before serving the internal router (echo),
// allowing you to adjust its options and attach new routes.
// allowing you to adjust its options and attach new routes or middlewares.
OnBeforeServe() *hook.Hook[*ServeEvent]
// OnBeforeApiError hook is triggered right before sending an error API
// response to the client, allowing you to further modify the error data
// or to return a completely different API response (using [hook.StopPropagation]).
// or to return a completely different API response.
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
// OnAfterApiError hook is triggered right after sending an error API
@@ -118,12 +153,16 @@ type App interface {
// It could be used to log the final API error in external services.
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
// OnTerminate hook is triggered when the app is in the process
// of being terminated (eg. on SIGTERM signal).
OnTerminate() *hook.Hook[*TerminateEvent]
// ---------------------------------------------------------------
// Dao event hooks
// ---------------------------------------------------------------
// OnModelBeforeCreate hook is triggered before inserting a new
// entry in the DB, allowing you to modify or validate the stored data.
// model in the DB, allowing you to modify or validate the stored data.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -131,7 +170,7 @@ type App interface {
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterCreate hook is triggered after successfully
// inserting a new entry in the DB.
// inserting a new model in the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -139,7 +178,7 @@ type App interface {
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeUpdate hook is triggered before updating existing
// entry in the DB, allowing you to modify or validate the stored data.
// model in the DB, allowing you to modify or validate the stored data.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -147,7 +186,7 @@ type App interface {
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterUpdate hook is triggered after successfully updating
// existing entry in the DB.
// existing model in the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -155,15 +194,15 @@ type App interface {
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeDelete hook is triggered before deleting an
// existing entry from the DB.
// existing model from the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterDelete is triggered after successfully deleting an
// existing entry from the DB.
// OnModelAfterDelete hook is triggered after successfully deleting an
// existing model from the DB.
//
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
@@ -174,22 +213,18 @@ type App interface {
// Mailer event hooks
// ---------------------------------------------------------------
// OnMailerBeforeAdminResetPasswordSend hook is triggered right before
// sending a password reset email to an admin.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// OnMailerBeforeAdminResetPasswordSend hook is triggered right
// before sending a password reset email to an admin, allowing you
// to inspect and customize the email message that is being sent.
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerAfterAdminResetPasswordSend hook is triggered after
// admin password reset email was successfully sent.
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
// sending a password reset email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// OnMailerBeforeRecordResetPasswordSend hook is triggered right
// before sending a password reset email to an auth record, allowing
// you to inspect and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -204,11 +239,9 @@ type App interface {
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeRecordVerificationSend hook is triggered right before
// sending a verification email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// OnMailerBeforeRecordVerificationSend hook is triggered right
// before sending a verification email to an auth record, allowing
// you to inspect and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -224,10 +257,8 @@ type App interface {
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
// sending a confirmation new address email to an auth record.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
// sending a confirmation new address email to an auth record, allowing
// you to inspect and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -289,8 +320,7 @@ type App interface {
// Settings update request (after request data load and before settings persistence).
//
// Could be used to additionally validate the request data or
// implement completely different persistence behavior
// (returning [hook.StopPropagation]).
// implement completely different persistence behavior.
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
// OnSettingsAfterUpdateRequest hook is triggered after each
@@ -307,6 +337,25 @@ type App interface {
// returning it to the client.
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
// OnFileBeforeTokenRequest hook is triggered before each file
// token API request.
//
// If no token or model was submitted, e.Model and e.Token will be empty,
// allowing you to implement your own custom model file auth implementation.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
// OnFileAfterTokenRequest hook is triggered after each
// successful file token API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
// ---------------------------------------------------------------
// Admin API event hooks
// ---------------------------------------------------------------
@@ -325,7 +374,7 @@ type App interface {
// Admin create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
// OnAdminAfterCreateRequest hook is triggered after each
@@ -336,7 +385,7 @@ type App interface {
// Admin update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
// OnAdminAfterUpdateRequest hook is triggered after each
@@ -347,7 +396,7 @@ type App interface {
// Admin delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
// completely different delete behavior.
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
// OnAdminAfterDeleteRequest hook is triggered after each
@@ -376,7 +425,7 @@ type App interface {
// auth refresh API request (right before generating a new auth token).
//
// Could be used to additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
// completely different auth refresh behavior.
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
// OnAdminAfterAuthRefreshRequest hook is triggered after each
@@ -387,7 +436,7 @@ type App interface {
// request password reset API request (after request data load and before sending the reset email).
//
// Could be used to additionally validate the request data or implement
// completely different password reset behavior (returning [hook.StopPropagation]).
// completely different password reset behavior.
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
@@ -398,7 +447,7 @@ type App interface {
// confirm password reset API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
@@ -424,7 +473,7 @@ type App interface {
// auth with password API request (after request data load and before password validation).
//
// Could be used to implement for example a custom password validation
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
// or to locate a different Record model (by reassigning [RecordAuthWithPasswordEvent.Record]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -442,11 +491,11 @@ type App interface {
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
//
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
// If the [RecordAuthWithOAuth2Event.Record] is not set, then the OAuth2
// request will try to create a new auth Record.
//
// To assign or link a different existing record model you can
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
// change the [RecordAuthWithOAuth2Event.Record] field.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -465,7 +514,7 @@ type App interface {
// auth refresh API request (right before generating a new auth token).
//
// Could be used to additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
// completely different auth refresh behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -480,11 +529,39 @@ type App interface {
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
//
// Could be used to validate or modify the response before returning it to the client.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API record external auth unlink request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
// request password reset API request (after request data load and before sending the reset email).
//
// Could be used to additionally validate the request data or implement
// completely different password reset behavior (returning [hook.StopPropagation]).
// completely different password reset behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -503,7 +580,7 @@ type App interface {
// confirm password reset API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -522,7 +599,7 @@ type App interface {
// request verification API request (after request data load and before sending the verification email).
//
// Could be used to additionally validate the loaded request data or implement
// completely different verification behavior (returning [hook.StopPropagation]).
// completely different verification behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -541,7 +618,7 @@ type App interface {
// confirm verification API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -560,7 +637,7 @@ type App interface {
// (after request data load and before sending the email link to confirm the change).
//
// Could be used to additionally validate the request data or implement
// completely different request email change behavior (returning [hook.StopPropagation]).
// completely different request email change behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -579,7 +656,7 @@ type App interface {
// confirm email change API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -594,34 +671,6 @@ type App interface {
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
//
// Could be used to validate or modify the response before returning it to the client.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API record external auth unlink request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record CRUD API event hooks
// ---------------------------------------------------------------
@@ -648,7 +697,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -667,7 +716,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -686,7 +735,7 @@ type App interface {
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
// completely different delete behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
@@ -719,7 +768,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
// OnCollectionAfterCreateRequest hook is triggered after each
@@ -730,7 +779,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
// completely different persistence behavior.
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
// OnCollectionAfterUpdateRequest hook is triggered after each
@@ -741,7 +790,7 @@ type App interface {
// Collection delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
// completely different delete behavior.
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionAfterDeleteRequest hook is triggered after each
@@ -752,7 +801,7 @@ type App interface {
// collections import request (after request data load and before the actual import).
//
// Could be used to additionally validate the imported collections or
// to implement completely different import behavior (returning [hook.StopPropagation]).
// to implement completely different import behavior.
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
// OnCollectionsAfterImportRequest hook is triggered after each
+132 -31
View File
@@ -7,6 +7,8 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"syscall"
"time"
"github.com/fatih/color"
@@ -27,6 +29,10 @@ const (
DefaultDataMaxIdleConns int = 20
DefaultLogsMaxOpenConns int = 10
DefaultLogsMaxIdleConns int = 2
LocalStorageDirName string = "storage"
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
)
var _ App = (*BaseApp)(nil)
@@ -55,6 +61,7 @@ type BaseApp struct {
onBeforeServe *hook.Hook[*ServeEvent]
onBeforeApiError *hook.Hook[*ApiErrorEvent]
onAfterApiError *hook.Hook[*ApiErrorEvent]
onTerminate *hook.Hook[*TerminateEvent]
// dao event hooks
onModelBeforeCreate *hook.Hook[*ModelEvent]
@@ -88,7 +95,9 @@ type BaseApp struct {
onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent]
// file api event hooks
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent]
onFileAfterTokenRequest *hook.Hook[*FileTokenEvent]
// admin api event hooks
onAdminsListRequest *hook.Hook[*AdminsListEvent]
@@ -171,7 +180,7 @@ type BaseAppConfig struct {
// configured with the provided arguments.
//
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config *BaseAppConfig) *BaseApp {
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
dataDir: config.DataDir,
isDebug: config.IsDebug,
@@ -190,6 +199,7 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
onBeforeServe: &hook.Hook[*ServeEvent]{},
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
onTerminate: &hook.Hook[*TerminateEvent]{},
// dao event hooks
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
@@ -223,7 +233,9 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{},
// file API event hooks
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{},
onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{},
// admin API event hooks
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
@@ -334,11 +346,10 @@ func (app *BaseApp) Bootstrap() error {
// we don't check for an error because the db migrations may have not been executed yet
app.RefreshSettings()
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
}
// cleanup the pb_data temp directory (if any)
os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
return nil
return app.OnAfterBootstrap().Trigger(event)
}
// ResetBootstrapState takes care for releasing initialized app resources
@@ -364,7 +375,6 @@ func (app *BaseApp) ResetBootstrapState() error {
app.dao = nil
app.logsDao = nil
app.settings = nil
return nil
}
@@ -460,6 +470,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
Password: app.Settings().Smtp.Password,
Tls: app.Settings().Smtp.Tls,
AuthMethod: app.Settings().Smtp.AuthMethod,
LocalName: app.Settings().Smtp.LocalName,
}
}
@@ -467,9 +478,10 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
}
// NewFilesystem creates a new local or S3 filesystem instance
// for managing regular app files (eg. collection uploads)
// based on the current app settings.
//
// NB! Make sure to call `Close()` on the returned result
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
if app.settings != nil && app.settings.S3.Enabled {
@@ -484,7 +496,54 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), "storage"))
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
// NewFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
if app.settings != nil && app.settings.Backups.S3.Enabled {
return filesystem.NewS3(
app.settings.Backups.S3.Bucket,
app.settings.Backups.S3.Region,
app.settings.Backups.S3.Endpoint,
app.settings.Backups.S3.AccessKey,
app.settings.Backups.S3.Secret,
app.settings.Backups.S3.ForcePathStyle,
)
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalBackupsDirName))
}
// Restart restarts (aka. replaces) the current running application process.
//
// NB! It relies on execve which is supported only on UNIX based systems.
func (app *BaseApp) Restart() error {
if runtime.GOOS == "windows" {
return errors.New("restart is not supported on windows")
}
execPath, err := os.Executable()
if err != nil {
return err
}
// optimistically reset the app bootstrap state
app.ResetBootstrapState()
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
// restart the app bootstrap state
app.Bootstrap()
return err
}
return nil
}
// RefreshSettings reinitializes and reloads the stored application settings.
@@ -537,6 +596,10 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
return app.onAfterApiError
}
func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
return app.onTerminate
}
// -------------------------------------------------------------------
// Dao event hooks
// -------------------------------------------------------------------
@@ -653,6 +716,14 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}
func (app *BaseApp) OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
return hook.NewTaggedHook(app.onFileBeforeTokenRequest, tags...)
}
func (app *BaseApp) OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
return hook.NewTaggedHook(app.onFileAfterTokenRequest, tags...)
}
// -------------------------------------------------------------------
// Admin API event hooks
// -------------------------------------------------------------------
@@ -978,37 +1049,58 @@ func (app *BaseApp) initDataDB() error {
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m})
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeCreate().Trigger(e, func(e *ModelEvent) error {
return action()
})
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelAfterCreate().Trigger(e)
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m})
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeUpdate().Trigger(e, func(e *ModelEvent) error {
return action()
})
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelAfterUpdate().Trigger(e)
}
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m})
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelBeforeDelete().Trigger(e, func(e *ModelEvent) error {
return action()
})
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
e := new(ModelEvent)
e.Dao = eventDao
e.Model = m
return app.OnModelAfterDelete().Trigger(e)
}
return dao
@@ -1051,4 +1143,13 @@ func (app *BaseApp) registerDefaultHooks() {
return nil
})
app.OnTerminate().Add(func(e *TerminateEvent) error {
app.ResetBootstrapState()
return nil
})
if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
log.Println(err)
}
}
+344
View File
@@ -0,0 +1,344 @@
package core
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"time"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/pocketbase/pocketbase/tools/security"
)
const CacheKeyActiveBackup string = "@activeBackup"
// CreateBackup creates a new backup of the current app pb_data directory.
//
// If name is empty, it will be autogenerated.
// If backup with the same name exists, the new backup file will replace it.
//
// The backup is executed within a transaction, meaning that new writes
// will be temporary "blocked" until the backup file is generated.
//
// To safely perform the backup, it is recommended to have free disk space
// for at least 2x the size of the pb_data directory.
//
// By default backups are stored in pb_data/backups
// (the backups directory itself is excluded from the generated backup).
//
// When using S3 storage for the uploaded collection files, you have to
// take care manually to backup those since they are not part of the pb_data.
//
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
if app.Cache().Has(CacheKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
if name == "" {
name = app.generateBackupName("pb_backup_")
}
app.Cache().Set(CacheKeyActiveBackup, name)
defer app.Cache().Remove(CacheKeyActiveBackup)
// root dir entries to exclude from the backup generation
exclude := []string{LocalBackupsDirName, LocalTempDirName}
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// Archive pb_data in a temp directory, exluding the "backups" and the temp dirs.
//
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
// ---
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// @todo consider experimenting with temp switching the readonly pragma after the db interface change
return archive.Create(app.DataDir(), tempPath, exclude...)
})
if createErr != nil {
return createErr
}
defer os.Remove(tempPath)
// Persist the backup in the backups filesystem.
// ---
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(ctx)
file, err := filesystem.NewFileFromPath(tempPath)
if err != nil {
return err
}
file.OriginalName = name
file.Name = file.OriginalName
if err := fsys.UploadFile(file, file.Name); err != nil {
return err
}
return nil
}
// RestoreBackup restores the backup with the specified name and restarts
// the current running application process.
//
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
//
// To safely perform the restore it is recommended to have free disk space
// for at least 2x the size of the restored pb_data backup.
//
// The performed steps are:
//
// 1. Download the backup with the specified name in a temp location
// (this is in case of S3; otherwise it creates a temp copy of the zip)
//
// 2. Extract the backup in a temp directory inside the app "pb_data"
// (eg. "pb_data/.pb_temp_to_delete/pb_restore").
//
// 3. Move the current app "pb_data" content (excluding the local backups and the special temp dir)
// under another temp sub dir that will be deleted on the next app start up
// (eg. "pb_data/.pb_temp_to_delete/old_pb_data").
// This is because on some environments it may not be allowed
// to delete the currently open "pb_data" files.
//
// 4. Move the extracted dir content to the app "pb_data".
//
// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
//
// If a failure occure during the restore process the dir changes are reverted.
// If for whatever reason the revert is not possible, it panics.
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
if runtime.GOOS == "windows" {
return errors.New("restore is not supported on windows")
}
if app.Cache().Has(CacheKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
app.Cache().Set(CacheKeyActiveBackup, name)
defer app.Cache().Remove(CacheKeyActiveBackup)
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(ctx)
// fetch the backup file in a temp location
br, err := fsys.GetFile(name)
if err != nil {
return err
}
defer br.Close()
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// create a temp zip file from the blob.Reader and try to extract it
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
if err != nil {
return err
}
defer os.Remove(tempZip.Name())
if _, err := io.Copy(tempZip, br); err != nil {
return err
}
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
defer os.RemoveAll(extractedDataDir)
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
return err
}
// ensure that a database file exists
extractedDB := filepath.Join(extractedDataDir, "data.db")
if _, err := os.Stat(extractedDB); err != nil {
return fmt.Errorf("data.db file is missing or invalid: %w", err)
}
// remove the extracted zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() {
log.Println(err)
}
// root dir entries to exclude from the backup restore
exclude := []string{LocalBackupsDirName, LocalTempDirName}
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
if err := osutils.MoveDirContent(app.DataDir(), oldTempDataDir, exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
// move the extracted archive content to the app's pb_data
if err := osutils.MoveDirContent(extractedDataDir, app.DataDir(), exclude...); err != nil {
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
}
revertDataDirChanges := func() error {
if err := osutils.MoveDirContent(app.DataDir(), extractedDataDir, exclude...); err != nil {
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
}
if err := osutils.MoveDirContent(oldTempDataDir, app.DataDir(), exclude...); err != nil {
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
}
return nil
}
// restart the app
if err := app.Restart(); err != nil {
if err := revertDataDirChanges(); err != nil {
panic(err)
}
return fmt.Errorf("failed to restart the app process: %w", err)
}
return nil
}
// initAutobackupHooks registers the autobackup app serve hooks.
func (app *BaseApp) initAutobackupHooks() error {
c := cron.New()
isServe := false
loadJob := func() {
c.Stop()
rawSchedule := app.Settings().Backups.Cron
if rawSchedule == "" || !isServe || !app.IsBootstrapped() {
return
}
c.Add("@autobackup", rawSchedule, func() {
const autoPrefix = "@auto_pb_backup_"
name := app.generateBackupName(autoPrefix)
if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
}
maxKeep := app.Settings().Backups.CronMaxKeep
if maxKeep == 0 {
return // no explicit limit
}
fsys, err := app.NewBackupsFilesystem()
if err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
return
}
defer fsys.Close()
files, err := fsys.List(autoPrefix)
if err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
return
}
if maxKeep >= len(files) {
return // nothing to remove
}
// sort desc
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime.After(files[j].ModTime)
})
// keep only the most recent n auto backup files
toRemove := files[maxKeep:]
for _, f := range toRemove {
if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
// @todo replace after logs generalization
log.Println(err)
}
}
})
// restart the ticker
c.Start()
}
// load on app serve
app.OnBeforeServe().Add(func(e *ServeEvent) error {
isServe = true
loadJob()
return nil
})
// stop the ticker on app termination
app.OnTerminate().Add(func(e *TerminateEvent) error {
c.Stop()
return nil
})
// reload on app settings change
app.OnModelAfterUpdate((&models.Param{}).TableName()).Add(func(e *ModelEvent) error {
p := e.Model.(*models.Param)
if p == nil || p.Key != models.ParamAppSettings {
return nil
}
loadJob()
return nil
})
return nil
}
func (app *BaseApp) generateBackupName(prefix string) string {
appName := inflector.Snakecase(app.Settings().Meta.AppName)
if len(appName) > 50 {
appName = appName[:50]
}
return fmt.Sprintf(
"%s%s_%s.zip",
prefix,
appName,
time.Now().UTC().Format("20060102150405"),
)
}
+164
View File
@@ -0,0 +1,164 @@
package core_test
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestCreateBackup(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// set some long app name with spaces and special characters
app.Settings().Meta.AppName = "test @! " + strings.Repeat("a", 100)
expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
// test pending error
app.Cache().Set(core.CacheKeyActiveBackup, "")
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
t.Fatal("Expected pending error, got nil")
}
app.Cache().Remove(core.CacheKeyActiveBackup)
// create with auto generated name
if err := app.CreateBackup(context.Background(), ""); err != nil {
t.Fatal("Failed to create a backup with autogenerated name")
}
// create with custom name
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
t.Fatal("Failed to create a backup with custom name")
}
// create new with the same name (aka. replace)
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
t.Fatal("Failed to create and replace a backup with the same name")
}
backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
entries, err := os.ReadDir(backupsDir)
if err != nil {
t.Fatal(err)
}
expectedFiles := []string{
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
"custom",
"custom.attrs",
}
if len(entries) != len(expectedFiles) {
names := getEntryNames(entries)
t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
}
for i, entry := range entries {
if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
}
if strings.HasSuffix(entry.Name(), ".attrs") {
continue
}
path := filepath.Join(backupsDir, entry.Name())
if err := verifyBackupContent(app, path); err != nil {
t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
}
}
}
func TestRestoreBackup(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create a initial test backup to ensure that there are at least 1
// backup file and that the generated zip doesn't contain the backups dir
if err := app.CreateBackup(context.Background(), "initial"); err != nil {
t.Fatal("Failed to create test initial backup")
}
// create test backup
if err := app.CreateBackup(context.Background(), "test"); err != nil {
t.Fatal("Failed to create test backup")
}
// test pending error
app.Cache().Set(core.CacheKeyActiveBackup, "")
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
t.Fatal("Expected pending error, got nil")
}
app.Cache().Remove(core.CacheKeyActiveBackup)
// missing backup
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
t.Fatal("Expected missing error, got nil")
}
}
// -------------------------------------------------------------------
func verifyBackupContent(app core.App, path string) error {
dir, err := os.MkdirTemp("", "backup_test")
if err != nil {
return err
}
defer os.RemoveAll(dir)
if err := archive.Extract(path, dir); err != nil {
return err
}
expectedRootEntries := []string{
"storage",
"data.db",
"data.db-shm",
"data.db-wal",
"logs.db",
"logs.db-shm",
"logs.db-wal",
".gitignore",
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
if len(entries) != len(expectedRootEntries) {
names := getEntryNames(entries)
return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
}
for _, entry := range entries {
if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
return fmt.Errorf("Didn't expect %q entry", entry.Name())
}
}
return nil
}
func getEntryNames(entries []fs.DirEntry) []string {
names := make([]string, len(entries))
for i, entry := range entries {
names[i] = entry.Name()
}
return names
}
+35 -9
View File
@@ -11,7 +11,7 @@ func TestNewBaseApp(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "test_env",
IsDebug: true,
@@ -46,7 +46,7 @@ func TestBaseAppBootstrap(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
@@ -118,17 +118,13 @@ func TestBaseAppBootstrap(t *testing.T) {
if app.logsDao != nil {
t.Fatalf("Expected app.logsDao to be nil, got %v.", app.logsDao)
}
if app.settings != nil {
t.Fatalf("Expected app.settings to be nil, got %v.", app.settings)
}
}
func TestBaseAppGetters(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
@@ -188,7 +184,7 @@ func TestBaseAppNewMailClient(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
@@ -211,7 +207,7 @@ func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
@@ -236,3 +232,33 @@ func TestBaseAppNewFilesystem(t *testing.T) {
t.Fatalf("Expected nil s3 filesystem, got %v", s3)
}
}
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local
local, localErr := app.NewBackupsFilesystem()
if localErr != nil {
t.Fatal(localErr)
}
if local == nil {
t.Fatal("Expected local backups filesystem instance, got nil")
}
// misconfigured s3
app.Settings().Backups.S3.Enabled = true
s3, s3Err := app.NewBackupsFilesystem()
if s3Err == nil {
t.Fatal("Expected S3 error, got nil")
}
if s3 != nil {
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
}
}
-20
View File
@@ -1,20 +0,0 @@
package core
import (
"github.com/pocketbase/dbx"
)
func initPragmas(db *dbx.DB) error {
// note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection
_, err := db.NewQuery(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = TRUE;
`).Execute()
return err
}
+36 -8
View File
@@ -3,18 +3,46 @@
package core
import (
"database/sql"
"github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
_ "github.com/mattn/go-sqlite3"
)
func connectDB(dbPath string) (*dbx.DB, error) {
db, err := dbx.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
func init() {
// Registers the sqlite3 driver with a ConnectHook so that we can
// initialize the default PRAGMAs.
//
// Note 1: we don't define the PRAGMA as part of the dsn string
// because not all pragmas are available.
//
// Note 2: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
sql.Register("pb_sqlite3",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
_, err := conn.Exec(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -16000;
`, nil)
if err := initPragmas(db); err != nil {
db.Close()
return err
},
},
)
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
}
func connectDB(dbPath string) (*dbx.DB, error) {
db, err := dbx.Open("pb_sqlite3", dbPath)
if err != nil {
return nil, err
}
+6 -6
View File
@@ -8,13 +8,13 @@ import (
)
func connectDB(dbPath string) (*dbx.DB, error) {
db, err := dbx.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
// Note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
if err := initPragmas(db); err != nil {
db.Close()
db, err := dbx.Open("sqlite", dbPath+pragmas)
if err != nil {
return nil, err
}
+43 -17
View File
@@ -1,6 +1,9 @@
package core
import (
"net/http"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
@@ -12,8 +15,30 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"golang.org/x/crypto/acme/autocert"
)
var (
_ hook.Tagger = (*BaseModelEvent)(nil)
_ hook.Tagger = (*BaseCollectionEvent)(nil)
)
type BaseModelEvent struct {
Model models.Model
}
func (e *BaseModelEvent) Tags() []string {
if e.Model == nil {
return nil
}
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
return []string{r.Collection().Id, r.Collection().Name}
}
return []string{e.Model.TableName()}
}
type BaseCollectionEvent struct {
Collection *models.Collection
}
@@ -44,9 +69,15 @@ type BootstrapEvent struct {
App App
}
type TerminateEvent struct {
App App
}
type ServeEvent struct {
App App
Router *echo.Echo
App App
Router *echo.Echo
Server *http.Server
CertManager *autocert.Manager
}
type ApiErrorEvent struct {
@@ -58,23 +89,10 @@ type ApiErrorEvent struct {
// Model DAO events data
// -------------------------------------------------------------------
var _ hook.Tagger = (*ModelEvent)(nil)
type ModelEvent struct {
Dao *daos.Dao
Model models.Model
}
BaseModelEvent
func (e *ModelEvent) Tags() []string {
if e.Model == nil {
return nil
}
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
return []string{r.Collection().Id, r.Collection().Name}
}
return []string{e.Model.TableName()}
Dao *daos.Dao
}
// -------------------------------------------------------------------
@@ -104,6 +122,7 @@ type MailerAdminEvent struct {
type RealtimeConnectEvent struct {
HttpContext echo.Context
Client subscriptions.Client
IdleTimeout time.Duration
}
type RealtimeDisconnectEvent struct {
@@ -379,6 +398,13 @@ type CollectionsImportEvent struct {
// File API events data
// -------------------------------------------------------------------
type FileTokenEvent struct {
BaseModelEvent
HttpContext echo.Context
Token string
}
type FileDownloadEvent struct {
BaseCollectionEvent
+133 -93
View File
@@ -5,6 +5,8 @@ package daos
import (
"errors"
"fmt"
"strings"
"time"
"github.com/pocketbase/dbx"
@@ -29,7 +31,8 @@ func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
}
// Dao handles various db operations.
// Think of Dao as a repository and service layer in one.
//
// You can think of Dao as a repository and service layer in one.
type Dao struct {
// in a transaction both refer to the same *dbx.TX instance
concurrentDB dbx.Builder
@@ -43,12 +46,13 @@ type Dao struct {
// This field has no effect if an explicit query context is already specified.
ModelQueryTimeout time.Duration
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
AfterCreateFunc func(eventDao *Dao, m models.Model)
BeforeUpdateFunc func(eventDao *Dao, m models.Model) error
AfterUpdateFunc func(eventDao *Dao, m models.Model)
BeforeDeleteFunc func(eventDao *Dao, m models.Model) error
AfterDeleteFunc func(eventDao *Dao, m models.Model)
// write hooks
BeforeCreateFunc func(eventDao *Dao, m models.Model, action func() error) error
AfterCreateFunc func(eventDao *Dao, m models.Model) error
BeforeUpdateFunc func(eventDao *Dao, m models.Model, action func() error) error
AfterUpdateFunc func(eventDao *Dao, m models.Model) error
BeforeDeleteFunc func(eventDao *Dao, m models.Model, action func() error) error
AfterDeleteFunc func(eventDao *Dao, m models.Model) error
}
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
@@ -81,6 +85,21 @@ func (dao *Dao) Clone() *Dao {
return &clone
}
// WithoutHooks returns a new Dao with the same configuration options
// as the current one, but without create/update/delete hooks.
func (dao *Dao) WithoutHooks() *Dao {
new := dao.Clone()
new.BeforeCreateFunc = nil
new.AfterCreateFunc = nil
new.BeforeUpdateFunc = nil
new.AfterUpdateFunc = nil
new.BeforeDeleteFunc = nil
new.AfterDeleteFunc = nil
return new
}
// ModelQuery creates a new preconfigured select query with preset
// SELECT, FROM and other common fields based on the provided model.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
@@ -134,56 +153,75 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
txDao := New(tx)
if dao.BeforeCreateFunc != nil {
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeCreateFunc(eventDao, m)
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
return dao.BeforeCreateFunc(eventDao, m, action)
}
}
if dao.BeforeUpdateFunc != nil {
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeUpdateFunc(eventDao, m)
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
return dao.BeforeUpdateFunc(eventDao, m, action)
}
}
if dao.BeforeDeleteFunc != nil {
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeDeleteFunc(eventDao, m)
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model, action func() error) error {
return dao.BeforeDeleteFunc(eventDao, m, action)
}
}
if dao.AfterCreateFunc != nil {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
return nil
}
}
if dao.AfterUpdateFunc != nil {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
return nil
}
}
if dao.AfterDeleteFunc != nil {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error {
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
return nil
}
}
return fn(txDao)
})
if txError == nil {
// execute after event calls on successful transaction
// (note: using the non-transaction dao to allow following queries in the after hooks)
for _, call := range afterCalls {
switch call.Action {
case "create":
dao.AfterCreateFunc(dao, call.Model)
case "update":
dao.AfterUpdateFunc(dao, call.Model)
case "delete":
dao.AfterDeleteFunc(dao, call.Model)
}
}
if txError != nil {
return txError
}
return txError
// execute after event calls on successful transaction
// (note: using the non-transaction dao to allow following queries in the after hooks)
var errs []error
for _, call := range afterCalls {
var err error
switch call.Action {
case "create":
err = dao.AfterCreateFunc(dao, call.Model)
case "update":
err = dao.AfterUpdateFunc(dao, call.Model)
case "delete":
err = dao.AfterDeleteFunc(dao, call.Model)
}
if err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
// @todo after go 1.20+ upgrade consider replacing with errors.Join()
var errsMsg strings.Builder
for _, err := range errs {
errsMsg.WriteString(err.Error())
errsMsg.WriteString("; ")
}
return fmt.Errorf("after transaction errors: %s", errsMsg.String())
}
return nil
}
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
@@ -196,21 +234,23 @@ func (dao *Dao) Delete(m models.Model) error {
}
return dao.lockRetry(func(retryDao *Dao) error {
if retryDao.BeforeDeleteFunc != nil {
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
action := func() error {
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
return err
}
if retryDao.AfterDeleteFunc != nil {
retryDao.AfterDeleteFunc(retryDao, m)
}
return nil
}
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
return err
if retryDao.BeforeDeleteFunc != nil {
return retryDao.BeforeDeleteFunc(retryDao, m, action)
}
if retryDao.AfterDeleteFunc != nil {
retryDao.AfterDeleteFunc(retryDao, m)
}
return nil
return action()
})
}
@@ -241,35 +281,35 @@ func (dao *Dao) update(m models.Model) error {
m.RefreshUpdated()
action := func() error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
_, err := dao.NonconcurrentDB().Update(
m.TableName(),
dataMap,
dbx.HashExp{"id": m.GetId()},
).Execute()
if err != nil {
return err
}
} else if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
return err
}
if dao.AfterUpdateFunc != nil {
return dao.AfterUpdateFunc(dao, m)
}
return nil
}
if dao.BeforeUpdateFunc != nil {
if err := dao.BeforeUpdateFunc(dao, m); err != nil {
return err
}
return dao.BeforeUpdateFunc(dao, m, action)
}
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
_, err := dao.NonconcurrentDB().Update(
m.TableName(),
dataMap,
dbx.HashExp{"id": m.GetId()},
).Execute()
if err != nil {
return err
}
} else {
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
return err
}
}
if dao.AfterUpdateFunc != nil {
dao.AfterUpdateFunc(dao, m)
}
return nil
return action()
}
func (dao *Dao) create(m models.Model) error {
@@ -289,36 +329,36 @@ func (dao *Dao) create(m models.Model) error {
m.RefreshUpdated()
}
action := func() error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
if _, ok := dataMap["id"]; !ok {
dataMap["id"] = m.GetId()
}
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
if err != nil {
return err
}
} else if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
return err
}
// clears the "new" model flag
m.MarkAsNotNew()
if dao.AfterCreateFunc != nil {
return dao.AfterCreateFunc(dao, m)
}
return nil
}
if dao.BeforeCreateFunc != nil {
if err := dao.BeforeCreateFunc(dao, m); err != nil {
return err
}
return dao.BeforeCreateFunc(dao, m, action)
}
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
if _, ok := dataMap["id"]; !ok {
dataMap["id"] = m.GetId()
}
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
if err != nil {
return err
}
} else {
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
return err
}
}
// clears the "new" model flag
m.MarkAsNotNew()
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(dao, m)
}
return nil
return action()
}
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
+131 -47
View File
@@ -49,33 +49,37 @@ func TestDaoClone(t *testing.T) {
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
dao.MaxLockRetries = 1
dao.ModelQueryTimeout = 2
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeDeleteFunc"]++
return nil
return action()
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeUpdateFunc"]++
return nil
return action()
}
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeCreateFunc"]++
return action()
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterDeleteFunc"]++
return nil
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["AfterDeleteFunc"]++
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterUpdateFunc"]++
return nil
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterCreateFunc"]++
return nil
}
clone := dao.Clone()
clone.MaxLockRetries = 3
clone.ModelQueryTimeout = 4
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["NewAfterCreateFunc"]++
return nil
}
if dao.MaxLockRetries == clone.MaxLockRetries {
@@ -86,16 +90,18 @@ func TestDaoClone(t *testing.T) {
t.Fatal("Expected different ModelQueryTimeout")
}
emptyAction := func() error { return nil }
// trigger hooks
dao.BeforeDeleteFunc(nil, nil)
dao.BeforeUpdateFunc(nil, nil)
dao.BeforeCreateFunc(nil, nil)
dao.BeforeDeleteFunc(nil, nil, emptyAction)
dao.BeforeUpdateFunc(nil, nil, emptyAction)
dao.BeforeCreateFunc(nil, nil, emptyAction)
dao.AfterDeleteFunc(nil, nil)
dao.AfterUpdateFunc(nil, nil)
dao.AfterCreateFunc(nil, nil)
clone.BeforeDeleteFunc(nil, nil)
clone.BeforeUpdateFunc(nil, nil)
clone.BeforeCreateFunc(nil, nil)
clone.BeforeDeleteFunc(nil, nil, emptyAction)
clone.BeforeUpdateFunc(nil, nil, emptyAction)
clone.BeforeCreateFunc(nil, nil, emptyAction)
clone.AfterDeleteFunc(nil, nil)
clone.AfterUpdateFunc(nil, nil)
clone.AfterCreateFunc(nil, nil)
@@ -120,6 +126,75 @@ func TestDaoClone(t *testing.T) {
}
}
func TestDaoWithoutHooks(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
hookCalls := map[string]int{}
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
dao.MaxLockRetries = 1
dao.ModelQueryTimeout = 2
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeDeleteFunc"]++
return action()
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeUpdateFunc"]++
return action()
}
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
hookCalls["BeforeCreateFunc"]++
return action()
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterDeleteFunc"]++
return nil
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterUpdateFunc"]++
return nil
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["AfterCreateFunc"]++
return nil
}
new := dao.WithoutHooks()
if new.MaxLockRetries != dao.MaxLockRetries {
t.Fatalf("Expected MaxLockRetries %d, got %d", new.Clone().MaxLockRetries, dao.MaxLockRetries)
}
if new.ModelQueryTimeout != dao.ModelQueryTimeout {
t.Fatalf("Expected ModelQueryTimeout %d, got %d", new.Clone().ModelQueryTimeout, dao.ModelQueryTimeout)
}
if new.BeforeDeleteFunc != nil {
t.Fatal("Expected BeforeDeleteFunc to be nil")
}
if new.BeforeUpdateFunc != nil {
t.Fatal("Expected BeforeUpdateFunc to be nil")
}
if new.BeforeCreateFunc != nil {
t.Fatal("Expected BeforeCreateFunc to be nil")
}
if new.AfterDeleteFunc != nil {
t.Fatal("Expected AfterDeleteFunc to be nil")
}
if new.AfterUpdateFunc != nil {
t.Fatal("Expected AfterUpdateFunc to be nil")
}
if new.AfterCreateFunc != nil {
t.Fatal("Expected AfterCreateFunc to be nil")
}
}
func TestDaoModelQuery(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -415,12 +490,13 @@ func TestDaoRetryCreate(t *testing.T) {
retryBeforeCreateHookCalls := 0
retryAfterCreateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeCreateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryAfterCreateHookCalls++
return nil
}
model := &models.Admin{Email: "new@example.com"}
@@ -441,7 +517,7 @@ func TestDaoRetryCreate(t *testing.T) {
// with non-locking error
retryBeforeCreateHookCalls = 0
retryAfterCreateHookCalls = 0
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeCreateHookCalls++
return errors.New("non-locking error")
}
@@ -473,12 +549,13 @@ func TestDaoRetryUpdate(t *testing.T) {
retryBeforeUpdateHookCalls := 0
retryAfterUpdateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeUpdateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryAfterUpdateHookCalls++
return nil
}
if err := retryDao.Save(model); err != nil {
@@ -498,7 +575,7 @@ func TestDaoRetryUpdate(t *testing.T) {
// with non-locking error
retryBeforeUpdateHookCalls = 0
retryAfterUpdateHookCalls = 0
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeUpdateHookCalls++
return errors.New("non-locking error")
}
@@ -524,12 +601,13 @@ func TestDaoRetryDelete(t *testing.T) {
retryBeforeDeleteHookCalls := 0
retryAfterDeleteHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeDeleteHookCalls++
return errors.New("database is locked")
}
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryAfterDeleteHookCalls++
return nil
}
model, _ := retryDao.FindAdminByEmail("test@example.com")
@@ -550,7 +628,7 @@ func TestDaoRetryDelete(t *testing.T) {
// with non-locking error
retryBeforeDeleteHookCalls = 0
retryAfterDeleteHookCalls = 0
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
retryBeforeDeleteHookCalls++
return errors.New("non-locking error")
}
@@ -577,13 +655,13 @@ func TestDaoBeforeHooksError(t *testing.T) {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
return errors.New("before_create")
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
return errors.New("before_update")
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
return errors.New("before_delete")
}
@@ -622,27 +700,30 @@ func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeCreateFuncCalls++
return nil
return action()
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeUpdateFuncCalls++
return nil
return action()
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeDeleteFuncCalls++
return nil
return action()
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterCreateFuncCalls++
return nil
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterUpdateFuncCalls++
return nil
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
afterDeleteFuncCalls++
return nil
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
@@ -710,27 +791,30 @@ func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeCreateFuncCalls++
return nil
return action()
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeUpdateFuncCalls++
return nil
return action()
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
beforeDeleteFuncCalls++
return nil
return action()
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterCreateFuncCalls++
return nil
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
afterUpdateFuncCalls++
return nil
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
afterDeleteFuncCalls++
return nil
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
+54
View File
@@ -378,6 +378,12 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
}
}
// wrap view query if necessary
query, err = txDao.normalizeViewQueryId(query)
if err != nil {
return fmt.Errorf("failed to normalize view query id: %w", err)
}
// (re)create the view
if err := txDao.SaveView(newCollection.Name, query); err != nil {
return err
@@ -389,6 +395,54 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
})
}
// @todo consider removing once custom id types are supported
//
// normalizeViewQueryId wraps (if necessary) the provided view query
// with a subselect to ensure that the id column is a text since
// currently we don't support non-string model ids
// (see https://github.com/pocketbase/pocketbase/issues/3110).
func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
query = strings.Trim(strings.TrimSpace(query), ";")
parsed, err := dao.parseQueryToFields(query)
if err != nil {
return "", err
}
needWrapping := true
idField := parsed[schema.FieldNameId]
if idField != nil && idField.field != nil &&
idField.field.Type != schema.FieldTypeJson &&
idField.field.Type != schema.FieldTypeNumber &&
idField.field.Type != schema.FieldTypeBool {
needWrapping = false
}
if !needWrapping {
return query, nil // no changes needed
}
// raw parse to preserve the columns order
rawParsed := new(identifiersParser)
if err := rawParsed.parse(query); err != nil {
return "", err
}
columns := make([]string, 0, len(rawParsed.columns))
for _, col := range rawParsed.columns {
if col.alias == schema.FieldNameId {
columns = append(columns, fmt.Sprintf("cast([[%s]] as text) [[%s]]", col.alias, col.alias))
} else {
columns = append(columns, "[["+col.alias+"]]")
}
}
query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
return query, nil
}
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
+136 -5
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
@@ -166,9 +167,9 @@ func TestDeleteCollection(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
colEmpty := &models.Collection{}
colUnsaved := &models.Collection{}
colAuth, err := app.Dao().FindCollectionByNameOrId("clients")
colAuth, err := app.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
@@ -187,6 +188,11 @@ func TestDeleteCollection(t *testing.T) {
t.Fatal(err)
}
colBase, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
@@ -201,12 +207,15 @@ func TestDeleteCollection(t *testing.T) {
model *models.Collection
expectError bool
}{
{colEmpty, true},
{colAuth, false},
{colUnsaved, true},
{colReferenced, true},
{colSystem, true},
{colBase, true}, // depend on view1, view2 and view2
{colView1, true}, // view2 depend on it
{colView2, false},
{colView1, false}, // no longer has dependent collections
{colBase, false}, // no longer has dependent views
{colAuth, false}, // should delete also its related external auths
}
for i, s := range scenarios {
@@ -225,6 +234,19 @@ func TestDeleteCollection(t *testing.T) {
if app.Dao().HasTable(s.model.Name) {
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
}
// check if the external auths were deleted
if s.model.IsAuth() {
var total int
err := app.Dao().ExternalAuthQuery().
Select("count(*)").
AndWhere(dbx.HashExp{"collectionId": s.model.Id}).
Row(&total)
if err != nil || total > 0 {
t.Fatalf("[%d] Expected external auths to be deleted, got %v (%v)", i, total, err)
}
}
}
}
@@ -372,8 +394,117 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
}
}
func TestSaveCollectionViewWrapping(t *testing.T) {
viewName := "test_wrapping"
scenarios := []struct {
name string
query string
expected string
}{
{
"no wrapping - text field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
},
{
"no wrapping - id field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
},
{
"no wrapping - relation field",
"select rel_one as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
},
{
"no wrapping - select field",
"select select_many as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
},
{
"no wrapping - email field",
"select email as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
},
{
"no wrapping - datetime field",
"select datetime as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
},
{
"no wrapping - url field",
"select url as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
},
{
"wrapping - bool field",
"select bool as id, text as txt, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
},
{
"wrapping - bool field (different order)",
"select text as txt, url, bool as id from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,cast(`id` as text) `id` FROM (select text as txt, url, bool as id from demo1))",
},
{
"wrapping - json field",
"select json as id, text, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
},
{
"wrapping - numeric id",
"select 1 as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select 1 as id))",
},
{
"wrapping - expresion",
"select ('test') as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select ('test') as id))",
},
{
"no wrapping - cast as text",
"select cast('test' as text) as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := &models.Collection{
Name: viewName,
Type: models.CollectionTypeView,
Options: types.JsonMap{
"query": s.query,
},
}
err := app.Dao().SaveCollection(collection)
if err != nil {
t.Fatal(err)
}
var sql string
rowErr := app.Dao().DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
Bind(dbx.Params{"name": viewName}).
Row(&sql)
if rowErr != nil {
t.Fatalf("Failed to retrieve view sql: %v", rowErr)
}
if sql != s.expected {
t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
}
})
}
}
func TestImportCollections(t *testing.T) {
totalCollections := 10
totalCollections := 11
scenarios := []struct {
name string
+267 -73
View File
@@ -1,6 +1,8 @@
package daos
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
@@ -8,86 +10,123 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// RecordQuery returns a new Record select query.
func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
tableName := collection.Name
// RecordQuery returns a new Record select query from a collection model, id or name.
//
// In case a collection id or name is provided and that collection doesn't
// actually exists, the generated query will be created with a cancelled context
// and will fail once an executor (Row(), One(), All(), etc.) is called.
func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
var tableName string
var collection *models.Collection
var collectionErr error
switch c := collectionModelOrIdentifier.(type) {
case *models.Collection:
collection = c
tableName = collection.Name
case models.Collection:
collection = &c
tableName = collection.Name
case string:
collection, collectionErr = dao.FindCollectionByNameOrId(c)
if collection != nil {
tableName = collection.Name
} else {
// update with some fake table name for easier debugging
tableName = "@@__missing_" + c
}
default:
// update with some fake table name for easier debugging
tableName = "@@__invalidCollectionModelOrIdentifier"
collectionErr = errors.New("unsupported collection identifier, must be collection model, id or name")
}
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
return dao.DB().
Select(selectCols).
From(tableName).
WithBuildHook(func(query *dbx.Query) {
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
switch v := a.(type) {
case *models.Record:
if v == nil {
return op(a)
}
query := dao.DB().Select(selectCols).From(tableName)
row := dbx.NullStringMap{}
if err := op(&row); err != nil {
return err
}
// in case of an error attach a new context and cancel it immediately with the error
if collectionErr != nil {
// @todo consider changing to WithCancelCause when upgrading
// the min Go requirement to 1.20, so that we can pass the error
ctx, cancelFunc := context.WithCancel(context.Background())
query.WithContext(ctx)
cancelFunc()
}
record := models.NewRecordFromNullStringMap(collection, row)
*v = *record
return nil
default:
return query.WithBuildHook(func(q *dbx.Query) {
q.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
switch v := a.(type) {
case *models.Record:
if v == nil {
return op(a)
}
}).
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
switch v := sliceA.(type) {
case *[]*models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
row := dbx.NullStringMap{}
if err := op(&row); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
record := models.NewRecordFromNullStringMap(collection, row)
*v = records
*v = *record
return nil
case *[]models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
nonPointers := make([]models.Record, len(records))
for i, r := range records {
nonPointers[i] = *r
}
*v = nonPointers
return nil
default:
return nil
default:
return op(a)
}
}).
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
switch v := sliceA.(type) {
case *[]*models.Record:
if v == nil {
return op(sliceA)
}
})
})
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
*v = records
return nil
case *[]models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
nonPointers := make([]models.Record, len(records))
for i, r := range records {
nonPointers[i] = *r
}
*v = nonPointers
return nil
default:
return op(sliceA)
}
})
})
}
// FindRecordById finds the Record model by its id.
@@ -158,6 +197,8 @@ func (dao *Dao) FindRecordsByIds(
return records, nil
}
// @todo consider to depricate as it may be easier to just use dao.RecordQuery()
//
// FindRecordsByExpr finds all records by the specified db expression.
//
// Returns all collection records if no expressions are provided.
@@ -170,12 +211,7 @@ func (dao *Dao) FindRecordsByIds(
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
// dao.FindRecordsByExpr("example", expr1, expr2)
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
query := dao.RecordQuery(collection)
query := dao.RecordQuery(collectionNameOrId)
// add only the non-nil expressions
for _, expr := range exprs {
@@ -200,14 +236,9 @@ func (dao *Dao) FindFirstRecordByData(
key string,
value any,
) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
record := &models.Record{}
err = dao.RecordQuery(collection).
err := dao.RecordQuery(collectionNameOrId).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(record)
@@ -218,6 +249,113 @@ func (dao *Dao) FindFirstRecordByData(
return record, nil
}
// FindRecordsByFilter returns limit number of records matching the
// provided string filter.
//
// NB! Use the last "params" argument to bind untrusted user variables!
//
// The sort argument is optional and can be empty string OR the same format
// used in the web APIs, eg. "-created,title".
//
// If the limit argument is <= 0, no limit is applied to the query and
// all matching records are returned.
//
// Example:
//
// dao.FindRecordsByFilter(
// "posts",
// "title ~ {:title} && visible = {:visible}",
// "-created",
// 10,
// 0,
// dbx.Params{"title": "lorem ipsum", "visible": true}
// )
func (dao *Dao) FindRecordsByFilter(
collectionNameOrId string,
filter string,
sort string,
limit int,
offset int,
params ...dbx.Params,
) ([]*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
q := dao.RecordQuery(collection)
// build a fields resolver and attach the generated conditions to the query
// ---
resolver := resolvers.NewRecordFieldResolver(
dao,
collection, // the base collection
nil, // no request data
true, // allow searching hidden/protected fields like "email"
)
expr, err := search.FilterData(filter).BuildExpr(resolver, params...)
if err != nil || expr == nil {
return nil, errors.New("invalid or empty filter expression")
}
q.AndWhere(expr)
if sort != "" {
for _, sortField := range search.ParseSortFromString(sort) {
expr, err := sortField.BuildExpr(resolver)
if err != nil {
return nil, err
}
if expr != "" {
q.AndOrderBy(expr)
}
}
}
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
// ---
if offset > 0 {
q.Offset(int64(offset))
}
if limit > 0 {
q.Limit(int64(limit))
}
records := []*models.Record{}
if err := q.All(&records); err != nil {
return nil, err
}
return records, nil
}
// FindFirstRecordByFilter returns the first available record matching the provided filter.
//
// NB! Use the last params argument to bind untrusted user variables!
//
// Example:
//
// dao.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
func (dao *Dao) FindFirstRecordByFilter(
collectionNameOrId string,
filter string,
params ...dbx.Params,
) (*models.Record, error) {
result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1, 0, params...)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, sql.ErrNoRows
}
return result[0], nil
}
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
//
// For correctness, if the collection is "auth" and the key is "username",
@@ -386,6 +524,62 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
return username
}
// CanAccessRecord checks if a record is allowed to be accessed by the
// specified requestInfo and accessRule.
//
// Rule and db checks are ignored in case requestInfo.Admin is set.
//
// The returned error indicate that something unexpected happened during
// the check (eg. invalid rule or db error).
//
// The method always return false on invalid access rule or db error.
//
// Example:
//
// requestInfo := apis.RequestInfo(c /* echo.Context */)
// record, _ := dao.FindRecordById("example", "RECORD_ID")
// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
//
// if ok, _ := dao.CanAccessRecord(record, requestInfo, rule); ok { ... }
func (dao *Dao) CanAccessRecord(record *models.Record, requestInfo *models.RequestInfo, accessRule *string) (bool, error) {
if requestInfo.Admin != nil {
// admins can access everything
return true, nil
}
if accessRule == nil {
// only admins can access this record
return false, nil
}
if *accessRule == "" {
// empty public rule, aka. everyone can access
return true, nil
}
var exists bool
query := dao.RecordQuery(record.Collection()).
Select("(1)").
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
// parse and apply the access rule filter
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return false, err
}
resolver.UpdateQuery(query)
query.AndWhere(expr)
if err := query.Limit(1).Row(&exists); err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
return exists, nil
}
// SaveRecord persists the provided Record model in the database.
//
// If record.IsNew() is true, the method will perform a create, otherwise an update.
+15 -7
View File
@@ -1,7 +1,6 @@
package daos
import (
"errors"
"fmt"
"regexp"
"strings"
@@ -24,21 +23,27 @@ type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]
// ExpandRecord expands the relations of a single Record model.
//
// If optFetchFunc is not set, then a default function will be used
// that returns all relation records.
//
// Returns a map with the failed expand parameters and their errors.
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc)
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
return dao.ExpandRecords([]*models.Record{record}, expands, optFetchFunc)
}
// ExpandRecords expands the relations of the provided Record models list.
//
// If optFetchFunc is not set, then a default function will be used
// that returns all relation records.
//
// Returns a map with the failed expand parameters and their errors.
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
normalized := normalizeExpands(expands)
failed := map[string]error{}
for _, expand := range normalized {
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
if err := dao.expandRecords(records, expand, optFetchFunc, 1); err != nil {
failed[expand] = err
}
}
@@ -49,13 +54,16 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
// notes:
// - fetchFunc must be non-nil func
// - if fetchFunc is nil, dao.FindRecordsByIds will be used
// - all records are expected to be from the same collection
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
// - indirect expands are supported only with single relation fields
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
if fetchFunc == nil {
return errors.New("Relation records fetchFunc is not set.")
// load a default fetchFunc
fetchFunc = func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
return dao.FindRecordsByIds(relCollection.Id, relIds)
}
}
if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 {
+26 -18
View File
@@ -48,15 +48,6 @@ func TestExpandRecords(t *testing.T) {
0,
0,
},
{
"empty fetchFunc",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
{
"fetchFunc with error",
"demo4",
@@ -101,6 +92,19 @@ func TestExpandRecords(t *testing.T) {
0,
1,
},
{
"with nil fetchfunc",
"users",
[]string{
"bgs820n361vj1qd",
"4q1xlclmfloku33",
"oap640cot4yru2s", // no rels
},
[]string{"rel"},
nil,
2,
0,
},
{
"expand normalizations",
"demo4",
@@ -132,6 +136,19 @@ func TestExpandRecords(t *testing.T) {
2,
0,
},
{
"with nil fetchfunc",
"users",
[]string{
"bgs820n361vj1qd",
"4q1xlclmfloku33",
"oap640cot4yru2s", // no rels
},
[]string{"rel"},
nil,
2,
0,
},
{
"maxExpandDepth reached",
"demo4",
@@ -228,15 +245,6 @@ func TestExpandRecord(t *testing.T) {
0,
0,
},
{
"empty fetchFunc",
"demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
{
"fetchFunc with error",
"demo4",
+64 -76
View File
@@ -10,7 +10,6 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -159,10 +158,6 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
return err
}
if err := txDao.syncRelationDisplayFieldsChanges(newCollection, renamedFieldNames, deletedFieldNames); err != nil {
return err
}
return txDao.createCollectionIndexes(newCollection)
})
}
@@ -173,10 +168,23 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
}
return dao.RunInTransaction(func(txDao *Dao) error {
// temporary disable the schema error checks to prevent view and trigger errors
// when "altering" (aka. deleting and recreating) the non-normalized columns
if _, err := txDao.DB().NewQuery("PRAGMA writable_schema = ON").Execute(); err != nil {
return err
}
// executed with defer to make sure that the pragma is always reverted
// in case of an error and when nested transactions are used
defer txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
for _, newField := range newCollection.Schema.Fields() {
oldField := oldCollection.Schema.GetFieldById(newField.Id)
if oldField == nil {
continue
// allow to continue even if there is no old field for the cases
// when a new field is added and there are already inserted data
var isOldMultiple bool
if oldField := oldCollection.Schema.GetFieldById(newField.Id); oldField != nil {
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
isOldMultiple = opt.IsMultiple()
}
}
var isNewMultiple bool
@@ -184,20 +192,30 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
isNewMultiple = opt.IsMultiple()
}
var isOldMultiple bool
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
isOldMultiple = opt.IsMultiple()
}
if isOldMultiple == isNewMultiple {
continue // no change
}
var updateQuery *dbx.Query
// update the column definition by:
// 1. inserting a new column with the new definition
// 2. copy normalized values from the original column to the new one
// 3. drop the original column
// 4. rename the new column to the original column
// -------------------------------------------------------
originalName := newField.Name
tempName := "_" + newField.Name + security.PseudorandomString(5)
_, err := txDao.DB().AddColumn(newCollection.Name, tempName, newField.ColDefinition()).Execute()
if err != nil {
return err
}
var copyQuery *dbx.Query
if !isOldMultiple && isNewMultiple {
// single -> multiple (convert to array)
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '') = ''
@@ -212,19 +230,19 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
END
)`,
newCollection.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
tempName,
originalName,
originalName,
originalName,
originalName,
originalName,
))
} else {
// multiple -> single (keep only the last element)
//
// note: for file fields the actual files are not deleted
// allowing additional custom handling via migration.
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
// note: for file fields the actual file objects are not
// deleted allowing additional custom handling via migration
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '[]') = '[]'
@@ -239,68 +257,38 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
END
)`,
newCollection.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
tempName,
originalName,
originalName,
originalName,
originalName,
originalName,
))
}
if _, err := updateQuery.Execute(); err != nil {
// copy the normalized values
if _, err := copyQuery.Execute(); err != nil {
return err
}
// drop the original column
if _, err := txDao.DB().DropColumn(newCollection.Name, originalName).Execute(); err != nil {
return err
}
// rename the new column back to the original
if _, err := txDao.DB().RenameColumn(newCollection.Name, tempName, originalName).Execute(); err != nil {
return err
}
}
return nil
// revert the pragma and reload the schema
_, revertErr := txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
return revertErr
})
}
func (dao *Dao) syncRelationDisplayFieldsChanges(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
return nil // nothing to sync
}
refs, err := dao.FindCollectionReferences(collection)
if err != nil {
return err
}
for refCollection, refFields := range refs {
for _, refField := range refFields {
options, _ := refField.Options.(*schema.RelationOptions)
if options == nil {
continue
}
// remove deleted (if any)
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
for old, new := range renamedFieldNames {
for i, name := range newDisplayFields {
if name == old {
newDisplayFields[i] = new
}
}
}
// has changes
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
options.DisplayFields = newDisplayFields
// direct collection save to prevent self-referencing
// recursion and unnecessary records table sync checks
if err := dao.Save(refCollection); err != nil {
return err
}
}
}
}
return nil
}
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
+117 -57
View File
@@ -171,89 +171,149 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
opt := relManyField.Options.(*schema.RelationOptions)
opt.MaxSelect = types.Pointer(1)
}
{
// new multivaluer field to check whether the array normalization
// will be applied for already inserted data
collection.Schema.AddField(&schema.SchemaField{
Name: "new_multiple",
Type: schema.FieldTypeSelect,
Options: &schema.SelectOptions{
Values: []string{"a", "b", "c"},
MaxSelect: 3,
},
})
}
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
type expectation struct {
SelectOne string `db:"select_one"`
SelectMany string `db:"select_many"`
FileOne string `db:"file_one"`
FileMany string `db:"file_many"`
RelOne string `db:"rel_one"`
RelMany string `db:"rel_many"`
// ensures that the writable schema was reverted to its expected default
var writableSchema bool
app.Dao().DB().NewQuery("PRAGMA writable_schema").Row(&writableSchema)
if writableSchema == true {
t.Fatalf("Expected writable_schema to be OFF, got %v", writableSchema)
}
scenarios := []struct {
// check whether the columns DEFAULT definition was updated
// ---------------------------------------------------------------
tableInfo, err := app.Dao().TableInfo(collection.Name)
if err != nil {
t.Fatal(err)
}
tableInfoExpectations := map[string]string{
"select_one": `'[]'`,
"select_many": `''`,
"file_one": `'[]'`,
"file_many": `''`,
"rel_one": `'[]'`,
"rel_many": `''`,
"new_multiple": `'[]'`,
}
for col, dflt := range tableInfoExpectations {
t.Run("check default for "+col, func(t *testing.T) {
var row *models.TableInfoRow
for _, r := range tableInfo {
if r.Name == col {
row = r
break
}
}
if row == nil {
t.Fatalf("Missing info for column %q", col)
}
if v := row.DefaultValue.String(); v != dflt {
t.Fatalf("Expected default value %q, got %q", dflt, v)
}
})
}
// check whether the values were normalized
// ---------------------------------------------------------------
type fieldsExpectation struct {
SelectOne string `db:"select_one"`
SelectMany string `db:"select_many"`
FileOne string `db:"file_one"`
FileMany string `db:"file_many"`
RelOne string `db:"rel_one"`
RelMany string `db:"rel_many"`
NewMultiple string `db:"new_multiple"`
}
fieldsScenarios := []struct {
recordId string
expected expectation
expected fieldsExpectation
}{
{
"imy661ixudk5izi",
expectation{
SelectOne: `[]`,
SelectMany: ``,
FileOne: `[]`,
FileMany: ``,
RelOne: `[]`,
RelMany: ``,
fieldsExpectation{
SelectOne: `[]`,
SelectMany: ``,
FileOne: `[]`,
FileMany: ``,
RelOne: `[]`,
RelMany: ``,
NewMultiple: `[]`,
},
},
{
"al1h9ijdeojtsjy",
expectation{
SelectOne: `["optionB"]`,
SelectMany: `optionB`,
FileOne: `["300_Jsjq7RdBgA.png"]`,
FileMany: ``,
RelOne: `["84nmscqy84lsi1t"]`,
RelMany: `oap640cot4yru2s`,
fieldsExpectation{
SelectOne: `["optionB"]`,
SelectMany: `optionB`,
FileOne: `["300_Jsjq7RdBgA.png"]`,
FileMany: ``,
RelOne: `["84nmscqy84lsi1t"]`,
RelMany: `oap640cot4yru2s`,
NewMultiple: `[]`,
},
},
{
"84nmscqy84lsi1t",
expectation{
SelectOne: `["optionB"]`,
SelectMany: `optionC`,
FileOne: `["test_d61b33QdDU.txt"]`,
FileMany: `test_tC1Yc87DfC.txt`,
RelOne: `[]`,
RelMany: `oap640cot4yru2s`,
fieldsExpectation{
SelectOne: `["optionB"]`,
SelectMany: `optionC`,
FileOne: `["test_d61b33QdDU.txt"]`,
FileMany: `test_tC1Yc87DfC.txt`,
RelOne: `[]`,
RelMany: `oap640cot4yru2s`,
NewMultiple: `[]`,
},
},
}
for _, s := range scenarios {
result := new(expectation)
for _, s := range fieldsScenarios {
t.Run("check fields for record "+s.recordId, func(t *testing.T) {
result := new(fieldsExpectation)
err := app.Dao().DB().Select(
"select_one",
"select_many",
"file_one",
"file_many",
"rel_one",
"rel_many",
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
if err != nil {
t.Errorf("[%s] Failed to load record: %v", s.recordId, err)
continue
}
err := app.Dao().DB().Select(
"select_one",
"select_many",
"file_one",
"file_many",
"rel_one",
"rel_many",
"new_multiple",
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
if err != nil {
t.Fatalf("Failed to load record: %v", err)
}
encodedResult, err := json.Marshal(result)
if err != nil {
t.Errorf("[%s] Failed to encode result: %v", s.recordId, err)
continue
}
encodedResult, err := json.Marshal(result)
if err != nil {
t.Fatalf("Failed to encode result: %v", err)
}
encodedExpectation, err := json.Marshal(s.expected)
if err != nil {
t.Errorf("[%s] Failed to encode expectation: %v", s.recordId, err)
continue
}
encodedExpectation, err := json.Marshal(s.expected)
if err != nil {
t.Fatalf("Failed to encode expectation: %v", err)
}
if !bytes.EqualFold(encodedExpectation, encodedResult) {
t.Errorf("[%s] Expected \n%s, \ngot \n%s", s.recordId, encodedExpectation, encodedResult)
}
if !bytes.EqualFold(encodedExpectation, encodedResult) {
t.Fatalf("Expected \n%s, \ngot \n%s", encodedExpectation, encodedResult)
}
})
}
}
+414 -6
View File
@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"testing"
@@ -19,7 +18,7 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRecordQuery(t *testing.T) {
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -28,11 +27,33 @@ func TestRecordQuery(t *testing.T) {
t.Fatal(err)
}
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
scenarios := []struct {
name any
collection any
expectedTotal int
expectError bool
}{
{"with nil value", nil, 0, true},
{"with invalid or missing collection id/name", "missing", 0, true},
{"with pointer model", collection, 3, false},
{"with value model", *collection, 3, false},
{"with name", "demo1", 3, false},
{"with id", "wsmn24bux7wo113", 3, false},
}
sql := app.Dao().RecordQuery(collection).Build().SQL()
if sql != expected {
t.Errorf("Expected sql %s, got %s", expected, sql)
for _, s := range scenarios {
var records []*models.Record
err := app.Dao().RecordQuery(s.collection).All(&records)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasError %v, got %v", s.name, s.expectError, hasErr)
continue
}
if total := len(records); total != s.expectedTotal {
t.Errorf("[%s] Expected %d records, got %d", s.name, s.expectedTotal, total)
}
}
}
@@ -405,6 +426,393 @@ func TestFindFirstRecordByData(t *testing.T) {
}
}
func TestFindRecordsByFilter(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
collectionIdOrName string
filter string
sort string
limit int
offset int
params []dbx.Params
expectError bool
expectRecordIds []string
}{
{
"missing collection",
"missing",
"id != ''",
"",
0,
0,
nil,
true,
nil,
},
{
"missing filter",
"demo2",
"",
"",
0,
0,
nil,
true,
nil,
},
{
"invalid filter",
"demo2",
"someMissingField > 1",
"",
0,
0,
nil,
true,
nil,
},
{
"simple filter",
"demo2",
"id != ''",
"",
0,
0,
nil,
false,
[]string{
"llvuca81nly1qls",
"achvryl401bhse3",
"0yxhwia2amd8gec",
},
},
{
"multi-condition filter with sort",
"demo2",
"id != '' && active=true",
"-created,title",
-1, // should behave the same as 0
0,
nil,
false,
[]string{
"0yxhwia2amd8gec",
"achvryl401bhse3",
},
},
{
"with limit and offset",
"demo2",
"id != ''",
"title",
2,
1,
nil,
false,
[]string{
"achvryl401bhse3",
"0yxhwia2amd8gec",
},
},
{
"with placeholder params",
"demo2",
"active = {:active}",
"",
10,
0,
[]dbx.Params{{"active": false}},
false,
[]string{
"llvuca81nly1qls",
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
records, err := app.Dao().FindRecordsByFilter(
s.collectionIdOrName,
s.filter,
s.sort,
s.limit,
s.offset,
s.params...,
)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
return
}
if len(records) != len(s.expectRecordIds) {
t.Fatalf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
}
for i, id := range s.expectRecordIds {
if id != records[i].Id {
t.Fatalf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
}
}
})
}
}
func TestFindFirstRecordByFilter(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
collectionIdOrName string
filter string
params []dbx.Params
expectError bool
expectRecordId string
}{
{
"missing collection",
"missing",
"id != ''",
nil,
true,
"",
},
{
"missing filter",
"demo2",
"",
nil,
true,
"",
},
{
"invalid filter",
"demo2",
"someMissingField > 1",
nil,
true,
"",
},
{
"valid filter but no matches",
"demo2",
"id = 'test'",
nil,
true,
"",
},
{
"valid filter and multiple matches",
"demo2",
"id != ''",
nil,
false,
"llvuca81nly1qls",
},
{
"with placeholder params",
"demo2",
"active = {:active}",
[]dbx.Params{{"active": false}},
false,
"llvuca81nly1qls",
},
}
for _, s := range scenarios {
record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter, s.params...)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if record.Id != s.expectRecordId {
t.Errorf("[%s] Expected record with id %q, got %q", s.name, s.expectRecordId, record.Id)
}
}
}
func TestCanAccessRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
admin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
authRecord, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
record, err := app.Dao().FindRecordById("demo1", "imy661ixudk5izi")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
record *models.Record
requestInfo *models.RequestInfo
rule *string
expected bool
expectError bool
}{
{
"as admin with nil rule",
record,
&models.RequestInfo{
Admin: admin,
},
nil,
true,
false,
},
{
"as admin with non-empty rule",
record,
&models.RequestInfo{
Admin: admin,
},
types.Pointer("id = ''"), // the filter rule should be ignored
true,
false,
},
{
"as admin with invalid rule",
record,
&models.RequestInfo{
Admin: admin,
},
types.Pointer("id ?!@ 1"), // the filter rule should be ignored
true,
false,
},
{
"as guest with nil rule",
record,
&models.RequestInfo{},
nil,
false,
false,
},
{
"as guest with empty rule",
record,
&models.RequestInfo{},
types.Pointer(""),
true,
false,
},
{
"as guest with invalid rule",
record,
&models.RequestInfo{},
types.Pointer("id ?!@ 1"),
false,
true,
},
{
"as guest with mismatched rule",
record,
&models.RequestInfo{},
types.Pointer("@request.auth.id != ''"),
false,
false,
},
{
"as guest with matched rule",
record,
&models.RequestInfo{
Data: map[string]any{"test": 1},
},
types.Pointer("@request.auth.id != '' || @request.data.test = 1"),
true,
false,
},
{
"as auth record with nil rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
},
nil,
false,
false,
},
{
"as auth record with empty rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
},
types.Pointer(""),
true,
false,
},
{
"as auth record with invalid rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
},
types.Pointer("id ?!@ 1"),
false,
true,
},
{
"as auth record with mismatched rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
Data: map[string]any{"test": 1},
},
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
false,
false,
},
{
"as auth record with matched rule",
record,
&models.RequestInfo{
AuthRecord: authRecord,
Data: map[string]any{"test": 2},
},
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
true,
false,
},
}
for _, s := range scenarios {
result, err := app.Dao().CanAccessRecord(s.record, s.requestInfo, s.rule)
if result != s.expected {
t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
}
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
}
}
func TestIsRecordValueUnique(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+55 -18
View File
@@ -43,10 +43,10 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
return err
}
trimmed := strings.Trim(selectQuery, ";")
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
// try to eagerly detect multiple inline statements
tk := tokenizer.NewFromString(trimmed)
tk := tokenizer.NewFromString(selectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
@@ -56,7 +56,7 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
//
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution.
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
return err
}
@@ -127,7 +127,7 @@ func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
}
if !hasId {
return errors.New("missing required id column (you ca use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
return errors.New("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
}
return nil
@@ -235,6 +235,8 @@ func defaultViewField(name string) *schema.SchemaField {
}
}
var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
p := new(identifiersParser)
if err := p.parse(selectQuery); err != nil {
@@ -257,15 +259,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
for _, col := range p.columns {
colLower := strings.ToLower(col.original)
// numeric expression cast
if strings.Contains(colLower, "(") &&
(strings.HasPrefix(colLower, "count(") ||
strings.HasPrefix(colLower, "total(") ||
strings.Contains(colLower, " as numeric") ||
strings.Contains(colLower, " as real") ||
strings.Contains(colLower, " as int") ||
strings.Contains(colLower, " as integer") ||
strings.Contains(colLower, " as decimal")) {
// numeric aggregations
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
@@ -275,6 +270,38 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
continue
}
castMatch := castRegex.FindStringSubmatch(colLower)
// numeric casts
if len(castMatch) == 2 {
switch castMatch[1] {
case "real", "integer", "int", "decimal", "numeric":
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeNumber,
},
}
continue
case "text":
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeText,
},
}
continue
case "boolean", "bool":
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeBool,
},
}
continue
}
}
parts := strings.Split(col.original, ".")
var fieldName string
@@ -311,6 +338,7 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
if field != nil {
clone := *field
clone.Id = "" // unset to prevent duplications if the same field is aliased multiple times
clone.Name = col.alias
result[col.alias] = &queryField{
field: &clone,
@@ -430,7 +458,7 @@ type identifiersParser struct {
}
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(selectQuery, ";")
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
@@ -462,6 +490,8 @@ func (p *identifiersParser) parse(selectQuery string) error {
skip = false
partType = "select"
activeBuilder = &selectParts
case "distinct":
continue // ignore as it is not important for the identifiers parsing
case "from":
skip = false
partType = "from"
@@ -477,7 +507,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
partType = "join"
activeBuilder = &joinParts
case "_discard_":
// do nothing...
// skip following tokens
skip = true
default:
isJoin := partType == "join"
@@ -569,13 +599,20 @@ func identifierFromParts(parts []string) (identifier, error) {
}
result.original = trimRawIdentifier(result.original)
result.alias = trimRawIdentifier(result.alias)
// we trim the single quote even though it is not a valid column quote character
// because SQLite allows it if the context expects an identifier and not string literal
// (https://www.sqlite.org/lang_keywords.html)
result.alias = trimRawIdentifier(result.alias, "'")
return result, nil
}
func trimRawIdentifier(rawIdentifier string) string {
const trimChars = "`\"[];"
func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
trimChars := "`\"[];"
if len(extraTrimChars) > 0 {
trimChars += strings.Join(extraTrimChars, "")
}
parts := strings.Split(rawIdentifier, ".")
+80 -43
View File
@@ -147,34 +147,33 @@ func TestSaveView(t *testing.T) {
}
for _, s := range scenarios {
err := app.Dao().SaveView(s.viewName, s.query)
t.Run(s.scenarioName, func(t *testing.T) {
err := app.Dao().SaveView(s.viewName, s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
infoRows, err := app.Dao().TableInfo(s.viewName)
if err != nil {
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
continue
}
if len(s.expectColumns) != len(infoRows) {
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
continue
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
}
if hasErr {
return
}
infoRows, err := app.Dao().TableInfo(s.viewName)
if err != nil {
t.Fatalf("Failed to fetch table info for %s: %v", s.viewName, err)
}
if len(s.expectColumns) != len(infoRows) {
t.Fatalf("Expected %d columns, got %d", len(s.expectColumns), len(infoRows))
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Fatalf("Missing %q column in %v", row.Name, s.expectColumns)
}
}
})
}
ensureNoTempViews(app, t)
@@ -272,24 +271,26 @@ func TestCreateViewSchema(t *testing.T) {
"datetime",
"json",
"rel_one",
"rel_many"
"rel_many",
'single_quoted_custom_literal' as 'single_quoted_column'
from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"bool": schema.FieldTypeBool,
"url": schema.FieldTypeUrl,
"select_one": schema.FieldTypeSelect,
"select_many": schema.FieldTypeSelect,
"file_one": schema.FieldTypeFile,
"file_many": schema.FieldTypeFile,
"number_alias": schema.FieldTypeNumber,
"email": schema.FieldTypeEmail,
"datetime": schema.FieldTypeDate,
"json": schema.FieldTypeJson,
"rel_one": schema.FieldTypeRelation,
"rel_many": schema.FieldTypeRelation,
"text": schema.FieldTypeText,
"bool": schema.FieldTypeBool,
"url": schema.FieldTypeUrl,
"select_one": schema.FieldTypeSelect,
"select_many": schema.FieldTypeSelect,
"file_one": schema.FieldTypeFile,
"file_many": schema.FieldTypeFile,
"number_alias": schema.FieldTypeNumber,
"email": schema.FieldTypeEmail,
"datetime": schema.FieldTypeDate,
"json": schema.FieldTypeJson,
"rel_one": schema.FieldTypeRelation,
"rel_many": schema.FieldTypeRelation,
"single_quoted_column": schema.FieldTypeJson,
},
},
{
@@ -330,7 +331,7 @@ func TestCreateViewSchema(t *testing.T) {
},
},
{
"query with numeric casts",
"query with casts",
`select
a.id,
count(a.id) count,
@@ -339,6 +340,9 @@ func TestCreateViewSchema(t *testing.T) {
cast(a.id as real) cast_real,
cast(a.id as decimal) cast_decimal,
cast(a.id as numeric) cast_numeric,
cast(a.id as text) cast_text,
cast(a.id as bool) cast_bool,
cast(a.id as boolean) cast_boolean,
avg(a.id) avg,
sum(a.id) sum,
total(a.id) total,
@@ -354,6 +358,9 @@ func TestCreateViewSchema(t *testing.T) {
"cast_real": schema.FieldTypeNumber,
"cast_decimal": schema.FieldTypeNumber,
"cast_numeric": schema.FieldTypeNumber,
"cast_text": schema.FieldTypeText,
"cast_bool": schema.FieldTypeBool,
"cast_boolean": schema.FieldTypeBool,
// json because they are nullable
"sum": schema.FieldTypeJson,
"avg": schema.FieldTypeJson,
@@ -395,8 +402,7 @@ func TestCreateViewSchema(t *testing.T) {
created created_alias,
updated updated_alias,
123 as custom
from demo1
`,
from demo1`,
false,
map[string]string{
"id2": schema.FieldTypeRelation,
@@ -409,6 +415,37 @@ func TestCreateViewSchema(t *testing.T) {
"custom": schema.FieldTypeJson,
},
},
{
"query with distinct and reordered id column",
`select distinct
id as id2,
id,
123 as custom
from demo1`,
false,
map[string]string{
"id2": schema.FieldTypeRelation,
"custom": schema.FieldTypeJson,
},
},
{
"query with aliasing the same field multiple times",
`select
a.id as id,
a.text as alias1,
a.text as alias2,
b.text as alias3,
b.text as alias4
from demo1 a
left join demo1 as b`,
false,
map[string]string{
"alias1": schema.FieldTypeText,
"alias2": schema.FieldTypeText,
"alias3": schema.FieldTypeText,
"alias4": schema.FieldTypeText,
},
},
}
for _, s := range scenarios {
+36 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/ghupdate"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
)
@@ -21,6 +22,30 @@ func main() {
// Optional plugin flags:
// ---------------------------------------------------------------
var hooksDir string
app.RootCmd.PersistentFlags().StringVar(
&hooksDir,
"hooksDir",
"",
"the directory with the JS app hooks",
)
var hooksWatch bool
app.RootCmd.PersistentFlags().BoolVar(
&hooksWatch,
"hooksWatch",
true,
"auto restart the app on pb_hooks file change",
)
var hooksPool int
app.RootCmd.PersistentFlags().IntVar(
&hooksPool,
"hooksPool",
25,
"the total prewarm goja.Runtime instances for the JS app hooks execution",
)
var migrationsDir string
app.RootCmd.PersistentFlags().StringVar(
&migrationsDir,
@@ -67,18 +92,24 @@ func main() {
// Plugins and hooks:
// ---------------------------------------------------------------
// load js pb_migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
Dir: migrationsDir,
// load jsvm (hooks and migrations)
jsvm.MustRegister(app, jsvm.Config{
MigrationsDir: migrationsDir,
HooksDir: hooksDir,
HooksWatch: hooksWatch,
HooksPoolSize: hooksPool,
})
// migrate command (with js templates)
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
// GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
return nil
@@ -101,5 +132,6 @@ func defaultPublicDir() string {
// most likely ran with go run
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}
+5 -4
View File
@@ -2,6 +2,7 @@ package forms
import (
"errors"
"fmt"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -66,7 +67,7 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
admin, err := form.dao.FindAdminByEmail(form.Email)
if err != nil {
return err
return fmt.Errorf("Failed to fetch admin with email %s: %w", form.Email, err)
}
now := time.Now().UTC()
@@ -75,14 +76,14 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
return errors.New("You have already requested a password reset.")
}
// update last sent timestamp
admin.LastResetSentAt = types.NowDateTime()
return runInterceptors(admin, func(m *models.Admin) error {
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
return err
}
// update last sent timestamp
m.LastResetSentAt = types.NowDateTime()
return form.dao.SaveAdmin(m)
}, interceptors...)
}
+2 -2
View File
@@ -117,7 +117,7 @@ func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastResetSentAt.String() == admin.LastResetSentAt.String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
if interceptorLastResetSentAt.String() != admin.LastResetSentAt.String() {
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
}
}
+79
View File
@@ -0,0 +1,79 @@
package forms
import (
"context"
"regexp"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
)
var backupNameRegex = regexp.MustCompile(`^[a-z0-9_-]+\.zip$`)
// BackupCreate is a request form for creating a new app backup.
type BackupCreate struct {
app core.App
ctx context.Context
Name string `form:"name" json:"name"`
}
// NewBackupCreate creates new BackupCreate request form.
func NewBackupCreate(app core.App) *BackupCreate {
return &BackupCreate{
app: app,
ctx: context.Background(),
}
}
// SetContext replaces the default form context with the provided one.
func (form *BackupCreate) SetContext(ctx context.Context) {
form.ctx = ctx
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *BackupCreate) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Name,
validation.Length(1, 100),
validation.Match(backupNameRegex),
validation.By(form.checkUniqueName),
),
)
}
func (form *BackupCreate) checkUniqueName(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
fsys, err := form.app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(form.ctx)
if exists, err := fsys.Exists(v); err != nil || exists {
return validation.NewError("validation_backup_name_exists", "The backup file name is invalid or already exists.")
}
return nil
}
// Submit validates the form and creates the app backup.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before creating the backup.
func (form *BackupCreate) Submit(interceptors ...InterceptorFunc[string]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(form.Name, func(name string) error {
return form.app.CreateBackup(form.ctx, name)
}, interceptors...)
}
+102
View File
@@ -0,0 +1,102 @@
package forms_test
import (
"strings"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestBackupCreateValidateAndSubmit(t *testing.T) {
scenarios := []struct {
name string
backupName string
expectedErrors []string
}{
{
"invalid length",
strings.Repeat("a", 97) + ".zip",
[]string{"name"},
},
{
"valid length + invalid format",
strings.Repeat("a", 96),
[]string{"name"},
},
{
"valid length + valid format",
strings.Repeat("a", 96) + ".zip",
[]string{},
},
{
"auto generated name",
"",
[]string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
fsys, err := app.NewBackupsFilesystem()
if err != nil {
t.Fatal(err)
}
defer fsys.Close()
form := forms.NewBackupCreate(app)
form.Name = s.backupName
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
return
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
// retrieve all created backup files
files, err := fsys.List("")
if err != nil {
t.Fatal("Failed to retrieve backup files")
return
}
if result != nil {
if total := len(files); total != 0 {
t.Fatalf("Didn't expected backup files, found %d", total)
}
return
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 backup file, got %d", total)
return
}
if s.backupName == "" {
prefix := "pb_backup_"
if !strings.HasPrefix(files[0].Key, prefix) {
t.Fatalf("Expected the backup file, to have prefix %q: %q", prefix, files[0].Key)
}
} else if s.backupName != files[0].Key {
t.Fatalf("Expected backup file %q, got %q", s.backupName, files[0].Key)
}
})
}
}
+85
View File
@@ -0,0 +1,85 @@
package forms
import (
"context"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
// BackupUpload is a request form for uploading a new app backup.
type BackupUpload struct {
app core.App
ctx context.Context
File *filesystem.File `json:"file"`
}
// NewBackupUpload creates new BackupUpload request form.
func NewBackupUpload(app core.App) *BackupUpload {
return &BackupUpload{
app: app,
ctx: context.Background(),
}
}
// SetContext replaces the default form upload context with the provided one.
func (form *BackupUpload) SetContext(ctx context.Context) {
form.ctx = ctx
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *BackupUpload) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.File,
validation.Required,
validation.By(validators.UploadedFileMimeType([]string{"application/zip"})),
validation.By(form.checkUniqueName),
),
)
}
func (form *BackupUpload) checkUniqueName(value any) error {
v, _ := value.(*filesystem.File)
if v == nil {
return nil // nothing to check
}
fsys, err := form.app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(form.ctx)
if exists, err := fsys.Exists(v.OriginalName); err != nil || exists {
return validation.NewError("validation_backup_name_exists", "Backup file with the specified name already exists.")
}
return nil
}
// Submit validates the form and upload the backup file.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before uploading the backup.
func (form *BackupUpload) Submit(interceptors ...InterceptorFunc[*filesystem.File]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(form.File, func(file *filesystem.File) error {
fsys, err := form.app.NewBackupsFilesystem()
if err != nil {
return err
}
fsys.SetContext(form.ctx)
return fsys.UploadFile(file, file.OriginalName)
}, interceptors...)
}
+118
View File
@@ -0,0 +1,118 @@
package forms_test
import (
"archive/zip"
"bytes"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
func TestBackupUploadValidateAndSubmit(t *testing.T) {
var zb bytes.Buffer
zw := zip.NewWriter(&zb)
if err := zw.Close(); err != nil {
t.Fatal(err)
}
f0, _ := filesystem.NewFileFromBytes([]byte("test"), "existing")
f1, _ := filesystem.NewFileFromBytes([]byte("456"), "nozip")
f2, _ := filesystem.NewFileFromBytes(zb.Bytes(), "existing")
f3, _ := filesystem.NewFileFromBytes(zb.Bytes(), "zip")
scenarios := []struct {
name string
file *filesystem.File
expectedErrors []string
}{
{
"missing file",
nil,
[]string{"file"},
},
{
"non-zip file",
f1,
[]string{"file"},
},
{
"zip file with non-unique name",
f2,
[]string{"file"},
},
{
"zip file with unique name",
f3,
[]string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
fsys, err := app.NewBackupsFilesystem()
if err != nil {
t.Fatal(err)
}
defer fsys.Close()
// create a dummy backup file to simulate existing backups
if err := fsys.UploadFile(f0, f0.OriginalName); err != nil {
t.Fatal(err)
}
form := forms.NewBackupUpload(app)
form.File = s.file
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
expectedFiles := []*filesystem.File{f0}
if result == nil {
expectedFiles = append(expectedFiles, s.file)
}
// retrieve all uploaded backup files
files, err := fsys.List("")
if err != nil {
t.Fatal("Failed to retrieve backup files")
}
if len(files) != len(expectedFiles) {
t.Fatalf("Expected %d files, got %d", len(expectedFiles), len(files))
}
for _, ef := range expectedFiles {
exists := false
for _, f := range files {
if f.Key == ef.OriginalName {
exists = true
break
}
}
if !exists {
t.Fatalf("Missing expected backup file %v", ef.OriginalName)
}
}
})
}
}
+12 -21
View File
@@ -229,14 +229,6 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
func (form *CollectionUpsert) checkRelationFields(value any) error {
v, _ := value.(schema.Schema)
systemDisplayFields := schema.BaseModelFieldNames()
systemDisplayFields = append(systemDisplayFields,
schema.FieldNameUsername,
schema.FieldNameEmail,
schema.FieldNameEmailVisibility,
schema.FieldNameVerified,
)
for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation {
continue
@@ -268,10 +260,10 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
}
}
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
relCollection, _ := form.dao.FindCollectionByNameOrId(options.CollectionId)
// validate collectionId
if err != nil || collection.Id != options.CollectionId {
if relCollection == nil || relCollection.Id != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
@@ -282,17 +274,16 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
}
}
// validate displayFields (if any)
for _, name := range options.DisplayFields {
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"displayFields": validation.NewError(
"validation_field_invalid_relation_displayFields",
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
),
}},
}
// allow only views to have relations to other views
// (see https://github.com/pocketbase/pocketbase/issues/3000)
if form.Type != models.CollectionTypeView && relCollection.IsView() {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_non_view_base_relation_collection",
"Non view collections are not allowed to have a view relation.",
),
}},
}
}
}
+170 -146
View File
@@ -171,25 +171,6 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
{
"create failure - missing relation display field",
"",
`{
"name": "test_new",
"type": "base",
"schema": [
{
"name":"test",
"type":"relation",
"options":{
"collectionId":"wsmn24bux7wo113",
"displayFields":["text", "missing"]
}
}
]
}`,
[]string{"schema"},
},
{
"create failure - check auth options validators",
"",
@@ -400,6 +381,54 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
// view tests
// -----------------------------------------------------------
{
"base->view relation",
"",
`{
"name": "test_view_relation",
"type": "base",
"schema": [
{
"name": "test",
"type": "relation",
"options":{
"collectionId": "v9gwnfh02gjq1q0"
}
}
]
}`,
[]string{"schema"}, // not allowed
},
{
"auth->view relation",
"",
`{
"name": "test_view_relation",
"type": "auth",
"schema": [
{
"name": "test",
"type": "relation",
"options": {
"collectionId": "v9gwnfh02gjq1q0"
}
}
]
}`,
[]string{"schema"}, // not allowed
},
{
"view->view relation",
"",
`{
"name": "test_view_relation",
"type": "view",
"options": {
"query": "select view1.id, view1.id as rel from view1"
}
}`,
[]string{}, // allowed
},
{
"view create failure",
"",
@@ -495,137 +524,132 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}
for _, s := range scenarios {
collection := &models.Collection{}
if s.existingName != "" {
var err error
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
if err != nil {
t.Fatal(err)
}
}
form := forms.NewCollectionUpsert(app, collection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
continue
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCalls++
return next(c)
}
}
// parse errors
result := form.Submit(interceptor)
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
continue
}
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
}
}
if len(s.expectedErrors) > 0 {
continue
}
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
if collection == nil {
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
continue
}
if form.Name != collection.Name {
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
}
if form.Type != collection.Type {
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
}
if form.System != collection.System {
t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
}
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
}
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
}
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
}
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
}
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
}
rawFormSchema, _ := form.Schema.MarshalJSON()
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
continue
}
for _, f := range form.Schema.Fields() {
if collection.Schema.GetFieldByName(f.Name) == nil {
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
continue
}
}
// check indexes (if any)
allIndexes, _ := app.Dao().TableIndexes(form.Name)
for _, formIdx := range form.Indexes {
parsed := dbutils.ParseIndex(formIdx)
parsed.TableName = form.Name
normalizedIdx := parsed.Build()
var exists bool
for _, idx := range allIndexes {
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
exists = true
continue
t.Run(s.testName, func(t *testing.T) {
collection := &models.Collection{}
if s.existingName != "" {
var err error
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
if err != nil {
t.Fatal(err)
}
}
if !exists {
t.Errorf(
"[%s] Missing index %s \nin \n%v", s.testName, normalizedIdx, allIndexes)
continue
form := forms.NewCollectionUpsert(app, collection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Fatalf("Failed to load form data: %v", loadErr)
}
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func(c *models.Collection) error {
interceptorCalls++
return next(c)
}
}
// parse errors
result := form.Submit(interceptor)
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Fatalf("Failed to parse errors %v", result)
}
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Fatalf("Expected interceptor to be called %d, got %d", expectInterceptorCalls, interceptorCalls)
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
if len(s.expectedErrors) > 0 {
return
}
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
if collection == nil {
t.Fatalf("Expected to find collection %q, got nil", form.Name)
}
if form.Name != collection.Name {
t.Fatalf("Expected Name %q, got %q", collection.Name, form.Name)
}
if form.Type != collection.Type {
t.Fatalf("Expected Type %q, got %q", collection.Type, form.Type)
}
if form.System != collection.System {
t.Fatalf("Expected System %v, got %v", collection.System, form.System)
}
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
t.Fatalf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
}
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
t.Fatalf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
}
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
t.Fatalf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
}
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
t.Fatalf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
}
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
t.Fatalf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
}
rawFormSchema, _ := form.Schema.MarshalJSON()
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
t.Fatalf("Expected Schema \n%v, \ngot \n%v", string(rawCollectionSchema), string(rawFormSchema))
}
for _, f := range form.Schema.Fields() {
if collection.Schema.GetFieldByName(f.Name) == nil {
t.Fatalf("Missing field %s \nin \n%v", f.Name, string(rawFormSchema))
}
}
// check indexes (if any)
allIndexes, _ := app.Dao().TableIndexes(form.Name)
for _, formIdx := range form.Indexes {
parsed := dbutils.ParseIndex(formIdx)
parsed.TableName = form.Name
normalizedIdx := parsed.Build()
var exists bool
for _, idx := range allIndexes {
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
exists = true
continue
}
}
if !exists {
t.Fatalf("Missing index %s \nin \n%v", normalizedIdx, allIndexes)
}
}
})
}
}
+2 -2
View File
@@ -38,7 +38,7 @@ func TestCollectionsImportValidate(t *testing.T) {
}
func TestCollectionsImportSubmit(t *testing.T) {
totalCollections := 10
totalCollections := 11
scenarios := []struct {
name string
@@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 7,
"OnModelBeforeDelete": 4,
},
},
{
+1 -2
View File
@@ -7,7 +7,6 @@ import (
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
@@ -89,7 +88,7 @@ func (form *RecordOAuth2Login) Validate() error {
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required),
validation.Field(&form.CodeVerifier, validation.Required),
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
validation.Field(&form.RedirectUrl, validation.Required),
)
}
+6
View File
@@ -49,6 +49,12 @@ func TestUserOauth2LoginValidate(t *testing.T) {
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{},
},
{
"[#3689] any redirectUrl value",
"users",
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"something"}`,
[]string{},
},
}
for _, s := range scenarios {
+5 -4
View File
@@ -2,6 +2,7 @@ package forms
import (
"errors"
"fmt"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -69,7 +70,7 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
if err != nil {
return err
return fmt.Errorf("Failed to fetch %s record with email %s: %w", form.collection.Id, form.Email, err)
}
now := time.Now().UTC()
@@ -78,14 +79,14 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*
return errors.New("You've already requested a password reset.")
}
// update last sent timestamp
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return runInterceptors(authRecord, func(m *models.Record) error {
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
return err
}
// update last sent timestamp
m.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return form.dao.SaveRecord(m)
}, interceptors...)
}
+2 -2
View File
@@ -164,7 +164,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
if interceptorLastResetSentAt.String() != authRecord.LastResetSentAt().String() {
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
}
}
+28 -20
View File
@@ -707,7 +707,7 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
defer tx.Rollback()
if err := txDao.SaveRecord(form.record); err != nil {
return err
return form.prepareError(err)
}
// restore record isNew state
@@ -744,42 +744,50 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
// upload new files (if any)
//
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hooks
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions
// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
// but BEFORE the actual record db persistence
// ---
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
if err := form.dao.BeforeCreateFunc(eventDao, m); err != nil {
return err
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
if err := form.processFilesToUpload(); err != nil {
return err
}
}
return action()
}
if m.GetId() == form.record.GetId() {
return form.processFilesToUpload()
if form.dao.BeforeCreateFunc != nil {
return form.dao.BeforeCreateFunc(eventDao, m, newAction)
}
return nil
return newAction()
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
if err := form.dao.BeforeUpdateFunc(eventDao, m); err != nil {
return err
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
newAction := func() error {
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
if err := form.processFilesToUpload(); err != nil {
return err
}
}
return action()
}
if m.GetId() == form.record.GetId() {
return form.processFilesToUpload()
if form.dao.BeforeUpdateFunc != nil {
return form.dao.BeforeUpdateFunc(eventDao, m, newAction)
}
return nil
return newAction()
}
// ---
// persist the record model
if err := dao.SaveRecord(form.record); err != nil {
preparedErr := form.prepareError(err)
if _, ok := preparedErr.(validation.Errors); ok {
return preparedErr
}
return fmt.Errorf("failed to save the record: %w", err)
return form.prepareError(err)
}
// delete old files (if any)
@@ -896,7 +904,7 @@ func (form *RecordUpsert) prepareError(err error) error {
c := form.record.Collection()
for _, f := range c.Schema.Fields() {
// blank space to unify multi-columns lookup
if strings.Contains(msg+" ", fmt.Sprintf("%s.%s ", strings.ToLower(c.Name), f.Name)) {
if strings.Contains(msg+" ", strings.ToLower(c.Name+"."+f.Name)) {
validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
}
}
+162 -37
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -11,14 +12,17 @@ import (
"strings"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
@@ -711,8 +715,11 @@ func TestRecordUpsertWithCustomId(t *testing.T) {
}
func TestRecordUpsertAuthRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
testName string
name string
existingId string
data map[string]any
manageAccess bool
@@ -906,9 +913,6 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
}
for _, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
@@ -919,7 +923,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
var err error
record, err = app.Dao().FindRecordById(collection.Id, s.existingId)
if err != nil {
t.Errorf("[%s] Failed to fetch auth record with id %s", s.testName, s.existingId)
t.Errorf("[%s] Failed to fetch auth record with id %s", s.name, s.existingId)
continue
}
}
@@ -927,7 +931,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
form := forms.NewRecordUpsert(app, record)
form.SetFullManageAccess(s.manageAccess)
if err := form.LoadData(s.data); err != nil {
t.Errorf("[%s] Failed to load form data", s.testName)
t.Errorf("[%s] Failed to load form data", s.name)
continue
}
@@ -935,11 +939,119 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
hasErr := submitErr != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.testName, s.expectError, hasErr, submitErr)
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
}
if !hasErr && record.Username() == "" {
t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.testName, record)
t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.name, record)
}
}
}
func TestRecordUpsertUniqueValidator(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create a dummy collection
collection := &models.Collection{
Name: "test",
Schema: schema.NewSchema(
&schema.SchemaField{
Type: "text",
Name: "fieldA",
},
&schema.SchemaField{
Type: "text",
Name: "fieldB",
},
&schema.SchemaField{
Type: "text",
Name: "fieldC",
},
),
Indexes: types.JsonArray[string]{
// the field case shouldn't matter
"create unique index unique_single_idx on test (fielda)",
"create unique index unique_combined_idx on test (fieldb, FIELDC)",
},
}
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
dummyRecord := models.NewRecord(collection)
dummyRecord.Set("fieldA", "a")
dummyRecord.Set("fieldB", "b")
dummyRecord.Set("fieldC", "c")
if err := app.Dao().SaveRecord(dummyRecord); err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
data map[string]any
expectedErrors []string
}{
{
"duplicated unique value",
map[string]any{
"fieldA": "a",
},
[]string{"fieldA"},
},
{
"duplicated combined unique value",
map[string]any{
"fieldB": "b",
"fieldC": "c",
},
[]string{"fieldB", "fieldC"},
},
{
"non-duplicated unique value",
map[string]any{
"fieldA": "a2",
},
nil,
},
{
"non-duplicated combined unique value",
map[string]any{
"fieldB": "b",
"fieldC": "d",
},
nil,
},
}
for _, s := range scenarios {
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
if err := form.LoadData(s.data); err != nil {
t.Errorf("[%s] Failed to load form data", s.name)
continue
}
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
continue
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
continue
}
}
}
}
@@ -1047,41 +1159,54 @@ func TestRecordUpsertUploadFailure(t *testing.T) {
t.Fatal(err)
}
// create with invalid file
{
new := models.NewRecord(collection)
new.Id = "123456789012341"
form := forms.NewRecordUpsert(app, new)
form.LoadData(map[string]any{"title": "new_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatal("Expected error, got nil")
}
if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
t.Fatalf("Expected the inserted record to be deleted, found \n%v", r.PublicExport())
}
testDaos := []*daos.Dao{
app.Dao(), // with hooks
daos.New(app.Dao().DB()), // without hooks
}
// update with invalid file
{
record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
if err != nil {
t.Fatal(err)
for i, dao := range testDaos {
// create with invalid file
{
prefix := fmt.Sprintf("%d-create", i)
new := models.NewRecord(collection)
new.Id = "123456789012341"
form := forms.NewRecordUpsert(app, new)
form.SetDao(dao)
form.LoadData(map[string]any{"title": "new_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatalf("[%s] Expected error, got nil", prefix)
}
if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
t.Fatalf("[%s] Expected the inserted record to be deleted, found \n%v", prefix, r.PublicExport())
}
}
form := forms.NewRecordUpsert(app, record)
form.LoadData(map[string]any{"title": "update_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
// update with invalid file
{
prefix := fmt.Sprintf("%d-update", i)
if err := form.Submit(); err == nil {
t.Fatal("Expected error, got nil")
}
record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
if err != nil {
t.Fatal(err)
}
if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
t.Fatalf("Expected the record changes to be reverted, got \n%v", r.PublicExport())
form := forms.NewRecordUpsert(app, record)
form.SetDao(dao)
form.LoadData(map[string]any{"title": "update_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatalf("[%s] Expected error, got nil", prefix)
}
if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
t.Fatalf("[%s] Expected the record changes to be reverted, got \n%v", prefix, r.PublicExport())
}
}
}
}
+3 -3
View File
@@ -82,9 +82,6 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*m
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
return errors.New("A verification email was already sent.")
}
// update last sent timestamp
record.SetLastVerificationSentAt(types.NowDateTime())
}
return runInterceptors(record, func(m *models.Record) error {
@@ -96,6 +93,9 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*m
return err
}
// update last sent timestamp
m.SetLastVerificationSentAt(types.NowDateTime())
return form.dao.SaveRecord(m)
}, interceptors...)
}
+2 -2
View File
@@ -182,7 +182,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastVerificationSentAt.String() == authRecord.LastVerificationSentAt().String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
if interceptorLastVerificationSentAt.String() != authRecord.LastVerificationSentAt().String() {
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
}
}
+15 -2
View File
@@ -58,8 +58,22 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
return runInterceptors(form.Settings, func(s *settings.Settings) error {
form.Settings = s
oldSettings, err := form.app.Settings().Clone()
if err != nil {
return err
}
// eagerly merge the application settings with the form ones
if err := form.app.Settings().Merge(form.Settings); err != nil {
return err
}
// persists settings change
encryptionKey := os.Getenv(form.app.EncryptionEnv())
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
// try to revert app settings
form.app.Settings().Merge(oldSettings)
return err
}
@@ -73,7 +87,6 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
form.app.LogsDao().Vacuum()
}
// merge the application settings with the form ones
return form.app.Settings().Merge(form.Settings)
return nil
}, interceptors...)
}
+44 -42
View File
@@ -24,56 +24,58 @@ func TestEmailSendValidateAndSubmit(t *testing.T) {
}
for i, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
func() {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewTestEmailSend(app)
form.Email = s.email
form.Template = s.template
form := forms.NewTestEmailSend(app)
form.Email = s.email
form.Template = s.template
result := form.Submit()
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%d) Failed to parse errors %v", i, result)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
continue
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
continue
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%d) Failed to parse errors %v", i, result)
return
}
}
expectedEmails := 1
if len(s.expectedErrors) > 0 {
expectedEmails = 0
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
return
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
return
}
}
if app.TestMailer.TotalSend != expectedEmails {
t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
}
expectedEmails := 1
if len(s.expectedErrors) > 0 {
expectedEmails = 0
}
if len(s.expectedErrors) > 0 {
continue
}
if app.TestMailer.TotalSend != expectedEmails {
t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
}
expectedContent := "Verify"
if s.template == "password-reset" {
expectedContent = "Reset password"
} else if s.template == "email-change" {
expectedContent = "Confirm new email"
}
if len(s.expectedErrors) > 0 {
return
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
}
expectedContent := "Verify"
if s.template == "password-reset" {
expectedContent = "Reset password"
} else if s.template == "email-change" {
expectedContent = "Confirm new email"
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
}
}()
}
}
+88
View File
@@ -0,0 +1,88 @@
package forms
import (
"errors"
"fmt"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
)
const (
s3FilesystemStorage = "storage"
s3FilesystemBackups = "backups"
)
// TestS3Filesystem defines a S3 filesystem connection test.
type TestS3Filesystem struct {
app core.App
// The name of the filesystem - storage or backups
Filesystem string `form:"filesystem" json:"filesystem"`
}
// NewTestS3Filesystem creates and initializes new TestS3Filesystem form.
func NewTestS3Filesystem(app core.App) *TestS3Filesystem {
return &TestS3Filesystem{app: app}
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *TestS3Filesystem) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Filesystem,
validation.Required,
validation.In(s3FilesystemStorage, s3FilesystemBackups),
),
)
}
// Submit validates and performs a S3 filesystem connection test.
func (form *TestS3Filesystem) Submit() error {
if err := form.Validate(); err != nil {
return err
}
var s3Config settings.S3Config
if form.Filesystem == s3FilesystemBackups {
s3Config = form.app.Settings().Backups.S3
} else {
s3Config = form.app.Settings().S3
}
if !s3Config.Enabled {
return errors.New("S3 storage filesystem is not enabled")
}
fsys, err := filesystem.NewS3(
s3Config.Bucket,
s3Config.Region,
s3Config.Endpoint,
s3Config.AccessKey,
s3Config.Secret,
s3Config.ForcePathStyle,
)
if err != nil {
return fmt.Errorf("failed to initialize the S3 filesystem: %w", err)
}
defer fsys.Close()
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
testFileKey := testPrefix + "/test.txt"
// try to upload a test file
if err := fsys.Upload([]byte("test"), testFileKey); err != nil {
return fmt.Errorf("failed to upload a test file: %w", err)
}
// test prefix deletion (ensures that both bucket list and delete works)
if errs := fsys.DeletePrefix(testPrefix); len(errs) > 0 {
return fmt.Errorf("failed to delete a test file: %w", errs[0])
}
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package forms_test
import (
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestS3FilesystemValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
filesystem string
expectedErrors []string
}{
{
"empty filesystem",
"",
[]string{"filesystem"},
},
{
"invalid filesystem",
"something",
[]string{"filesystem"},
},
{
"backups filesystem",
"backups",
[]string{},
},
{
"storage filesystem",
"storage",
[]string{},
},
}
for _, s := range scenarios {
form := forms.NewTestS3Filesystem(app)
form.Filesystem = s.filesystem
result := form.Validate()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
continue
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
}
}
}
}
func TestS3FilesystemSubmitFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// check if validate was called
{
form := forms.NewTestS3Filesystem(app)
form.Filesystem = ""
result := form.Submit()
if result == nil {
t.Fatal("Expected error, got nil")
}
if _, ok := result.(validation.Errors); !ok {
t.Fatalf("Expected validation.Error, got %v", result)
}
}
// check with valid storage and disabled s3
{
form := forms.NewTestS3Filesystem(app)
form.Filesystem = "storage"
result := form.Submit()
if result == nil {
t.Fatal("Expected error, got nil")
}
if _, ok := result.(validation.Error); ok {
t.Fatalf("Didn't expect validation.Error, got %v", result)
}
}
}
+4
View File
@@ -158,6 +158,10 @@ func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField
options, _ := field.Options.(*schema.NumberOptions)
if options.NoDecimal && val != float64(int64(val)) {
return validation.NewError("validation_no_decimal_constraint", "Decimal numbers are not allowed")
}
if options.Min != nil && val < *options.Min {
return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *options.Min))
}
+19
View File
@@ -180,6 +180,13 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
Max: &max,
},
},
&schema.SchemaField{
Name: "field4",
Type: schema.FieldTypeNumber,
Options: &schema.NumberOptions{
NoDecimal: true,
},
},
)
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
@@ -201,6 +208,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
"field1": nil,
"field2": nil,
"field3": nil,
"field4": nil,
},
nil,
[]string{"field2"},
@@ -211,6 +219,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
"field1": "invalid",
"field2": "invalid",
"field3": "invalid",
"field4": "invalid",
},
nil,
[]string{"field2"},
@@ -244,6 +253,15 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
nil,
[]string{"field3"},
},
{
"(number) check NoDecimal",
map[string]any{
"field2": 1,
"field4": 456.789,
},
nil,
[]string{"field4"},
},
{
"(number) valid data (only required)",
map[string]any{
@@ -258,6 +276,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
"field1": nil,
"field2": 123, // test value cast
"field3": max,
"field4": 456,
},
nil,
[]string{},
+63 -58
View File
@@ -1,91 +1,96 @@
module github.com/pocketbase/pocketbase
go 1.18
go 1.19
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/aws/aws-sdk-go-v2 v1.17.7
github.com/aws/aws-sdk-go-v2/config v1.18.19
github.com/aws/aws-sdk-go-v2/credentials v1.13.18
github.com/aws/aws-sdk-go-v2/service/s3 v1.31.1
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/aws/aws-sdk-go v1.46.6
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.5.0
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de
github.com/dop251/goja_nodejs v0.0.0-20230914102007-198ba9a8b098
github.com/fatih/color v1.15.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/fsnotify/fsnotify v1.6.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/ganigeorgiev/fexpr v0.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/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
github.com/mattn/go-sqlite3 v1.14.16
github.com/pocketbase/dbx v1.10.0
github.com/spf13/cast v1.5.0
github.com/spf13/cobra v1.6.1
gocloud.dev v0.29.0
golang.org/x/crypto v0.7.0
golang.org/x/net v0.8.0
golang.org/x/oauth2 v0.6.0
modernc.org/sqlite v1.21.1
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/mattn/go-sqlite3 v1.14.17
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/tygoja v0.0.0-20231028133025-25a94364de58
github.com/spf13/cast v1.5.1
github.com/spf13/cobra v1.7.0
gocloud.dev v0.34.0
golang.org/x/crypto v0.14.0
golang.org/x/net v0.17.0
golang.org/x/oauth2 v0.13.0
golang.org/x/sync v0.4.0
modernc.org/sqlite v1.26.0
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.44.234 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.60 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.21.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/config v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.43 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.92 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.45 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.38 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.37 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.6 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.40.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.15.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.23.2 // indirect
github.com/aws/smithy-go v1.15.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/pprof v0.0.0-20230912144702-c363fe2c2ed8 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.6.0 // indirect
golang.org/x/mod v0.9.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.7.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.114.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633 // indirect
google.golang.org/grpc v1.54.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
golang.org/x/tools v0.14.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/api v0.148.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.28.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+166 -2694
View File
File diff suppressed because it is too large Load Diff
+5 -10
View File
@@ -2,7 +2,6 @@ package mails
import (
"fmt"
"log"
"net/mail"
"github.com/pocketbase/pocketbase/core"
@@ -67,15 +66,11 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
event.Admin = admin
event.Meta = map[string]any{"token": token}
sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
if err := app.OnMailerAfterAdminResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
return app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
if err := e.MailClient.Send(e.Message); err != nil {
return err
}
}
return sendErr
return app.OnMailerAfterAdminResetPasswordSend().Trigger(e)
})
}
+15 -28
View File
@@ -2,7 +2,6 @@ package mails
import (
"html/template"
"log"
"net/mail"
"github.com/pocketbase/pocketbase/core"
@@ -44,17 +43,13 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
event.Record = authRecord
event.Meta = map[string]any{"token": token}
sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
if err := app.OnMailerAfterRecordResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
return app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
if err := e.MailClient.Send(e.Message); err != nil {
return err
}
}
return sendErr
return app.OnMailerAfterRecordResetPasswordSend().Trigger(e)
})
}
// SendRecordVerification sends a verification request email to the specified user.
@@ -88,17 +83,13 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
event.Record = authRecord
event.Meta = map[string]any{"token": token}
sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
if err := app.OnMailerAfterRecordVerificationSend().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
return app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
if err := e.MailClient.Send(e.Message); err != nil {
return err
}
}
return sendErr
return app.OnMailerAfterRecordVerificationSend().Trigger(e)
})
}
// SendUserChangeEmail sends a change email confirmation email to the specified user.
@@ -135,17 +126,13 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
"newEmail": newEmail,
}
sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
if err := app.OnMailerAfterRecordChangeEmailSend().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
return app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
if err := e.MailClient.Send(e.Message); err != nil {
return err
}
}
return sendErr
return app.OnMailerAfterRecordChangeEmailSend().Trigger(e)
})
}
func resolveEmailTemplate(
+12 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -90,7 +91,16 @@ func init() {
return tablesErr
}
// inserts the system profiles collection
dao := daos.New(db)
// inserts default settings
// -----------------------------------------------------------
defaultSettings := settings.New()
if err := dao.SaveSettings(defaultSettings); err != nil {
return err
}
// inserts the system users collection
// -----------------------------------------------------------
usersCollection := &models.Collection{}
usersCollection.MarkAsNew()
@@ -139,7 +149,7 @@ func init() {
},
)
return daos.New(db).SaveCollection(usersCollection)
return dao.SaveCollection(usersCollection)
}, func(db dbx.Builder) error {
tables := []string{
"users",
@@ -12,93 +12,97 @@ import (
// Normalizes old single and multiple values of MultiValuer fields (file, select, relation).
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, c := range collections {
if c.IsView() {
// skip view collections
continue
}
for _, f := range c.Schema.Fields() {
opt, ok := f.Options.(schema.MultiValuer)
if !ok {
continue
}
var updateQuery *dbx.Query
if opt.IsMultiple() {
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '') = ''
THEN '[]'
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN [[%s]]
ELSE json_array([[%s]])
END
)
END
)`,
c.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
))
} else {
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '[]') = '[]'
THEN ''
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
ELSE [[%s]]
END
)
END
)`,
c.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
))
}
if _, err := updateQuery.Execute(); err != nil {
return err
}
}
}
// trigger view query update after the records normalization
// (ignore save error in case of invalid query to allow users to change it from the UI)
for _, c := range collections {
if !c.IsView() {
continue
}
dao.SaveCollection(c)
}
return nil
return normalizeMultivaluerFields(db)
}, func(db dbx.Builder) error {
return nil
})
}
func normalizeMultivaluerFields(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, c := range collections {
if c.IsView() {
// skip view collections
continue
}
for _, f := range c.Schema.Fields() {
opt, ok := f.Options.(schema.MultiValuer)
if !ok {
continue
}
var updateQuery *dbx.Query
if opt.IsMultiple() {
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '') = ''
THEN '[]'
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN [[%s]]
ELSE json_array([[%s]])
END
)
END
)`,
c.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
))
} else {
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '[]') = '[]'
THEN ''
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
ELSE [[%s]]
END
)
END
)`,
c.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
))
}
if _, err := updateQuery.Execute(); err != nil {
return err
}
}
}
// trigger view query update after the records normalization
// (ignore save error in case of invalid query to allow users to change it from the UI)
for _, c := range collections {
if !c.IsView() {
continue
}
dao.SaveCollection(c)
}
return nil
}
+8 -3
View File
@@ -42,14 +42,19 @@ func init() {
return err
}
var hasIndexesColumn bool
for _, col := range cols {
if col == "indexes" {
return nil // already existing (probably via the init migration)
// already existing (probably via the init migration)
hasIndexesColumn = true
break
}
}
if _, err := db.AddColumn("_collections", "indexes", `JSON DEFAULT "[]" NOT NULL`).Execute(); err != nil {
return err
if !hasIndexesColumn {
if _, err := db.AddColumn("_collections", "indexes", `JSON DEFAULT "[]" NOT NULL`).Execute(); err != nil {
return err
}
}
collections := []*models.Collection{}
+20
View File
@@ -0,0 +1,20 @@
package migrations
import (
"github.com/pocketbase/dbx"
)
// Cleanup dangling deleted collections references
// (see https://github.com/pocketbase/pocketbase/discussions/2570).
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
_, err := db.NewQuery(`
DELETE FROM {{_externalAuths}}
WHERE [[collectionId]] NOT IN (SELECT [[id]] FROM {{_collections}})
`).Execute()
return err
}, func(db dbx.Builder) error {
return nil
})
}
@@ -0,0 +1,15 @@
package migrations
import (
"github.com/pocketbase/dbx"
)
// Renormalizes old single and multiple values of MultiValuer fields (file, select, relation)
// (see https://github.com/pocketbase/pocketbase/issues/2930).
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
return normalizeMultivaluerFields(db)
}, func(db dbx.Builder) error {
return nil
})
}
@@ -0,0 +1,58 @@
package migrations
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// Reset all previously inserted NULL values to the fields zero-default.
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, collection := range collections {
if collection.IsView() {
continue
}
for _, f := range collection.Schema.Fields() {
defaultVal := "''"
switch f.Type {
case schema.FieldTypeJson:
continue
case schema.FieldTypeBool:
defaultVal = "FALSE"
case schema.FieldTypeNumber:
defaultVal = "0"
default:
if opt, ok := f.Options.(schema.MultiValuer); ok && opt.IsMultiple() {
defaultVal = "'[]'"
}
}
_, err := db.NewQuery(fmt.Sprintf(
"UPDATE {{%s}} SET [[%s]] = %s WHERE [[%s]] IS NULL",
collection.Name,
f.Name,
defaultVal,
f.Name,
)).Execute()
if err != nil {
return err
}
}
}
return nil
}, nil)
}
@@ -0,0 +1,61 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// Transform the relation fields to views from non-view collections to json or text fields
// (see https://github.com/pocketbase/pocketbase/issues/3000).
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
views, err := dao.FindCollectionsByType(models.CollectionTypeView)
if err != nil {
return err
}
for _, view := range views {
refs, err := dao.FindCollectionReferences(view)
if err != nil {
return nil
}
for collection, fields := range refs {
if collection.IsView() {
continue // view-view relations are allowed
}
for _, f := range fields {
opt, ok := f.Options.(schema.MultiValuer)
if !ok {
continue
}
if opt.IsMultiple() {
f.Type = schema.FieldTypeJson
f.Options = &schema.JsonOptions{}
} else {
f.Type = schema.FieldTypeText
f.Options = &schema.TextOptions{}
}
// replace the existing field
// (this usually is not necessary since it is a pointer,
// but it is better to be explicit in case FindCollectionReferences changes)
collection.Schema.AddField(f)
}
// "raw" save without records table sync
if err := dao.Save(collection); err != nil {
return err
}
}
}
return nil
}, nil)
}
+28
View File
@@ -0,0 +1,28 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// Resave all view collections to ensure that the proper id normalization is applied.
// (see https://github.com/pocketbase/pocketbase/issues/3110)
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
if err != nil {
return nil
}
for _, collection := range collections {
// ignore errors to allow users to adjust
// the view queries after app start
dao.SaveCollection(collection)
}
return nil
}, nil)
}
@@ -0,0 +1,62 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// Copy the now deprecated RelationOptions.DisplayFields values from
// all relation fields and register its value as Presentable under
// the specific field in the related collection.
//
// If there is more than one relation to a single collection with explicitly
// set DisplayFields only one of the configuration will be copied.
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
indexedCollections := make(map[string]*models.Collection, len(collections))
for _, collection := range collections {
indexedCollections[collection.Id] = collection
}
for _, collection := range indexedCollections {
for _, f := range collection.Schema.Fields() {
if f.Type != schema.FieldTypeRelation {
continue
}
options, ok := f.Options.(*schema.RelationOptions)
if !ok || len(options.DisplayFields) == 0 {
continue
}
relCollection, ok := indexedCollections[options.CollectionId]
if !ok {
continue
}
for _, name := range options.DisplayFields {
relField := relCollection.Schema.GetFieldByName(name)
if relField != nil {
relField.Presentable = true
}
}
// only raw model save
if err := dao.Save(relCollection); err != nil {
return err
}
}
}
return nil
}, nil)
}
+9
View File
@@ -0,0 +1,9 @@
package models
import "github.com/pocketbase/pocketbase/tools/types"
type BackupFileInfo struct {
Key string `json:"key"`
Size int64 `json:"size"`
Modified types.DateTime `json:"modified"`
}
+74 -7
View File
@@ -279,9 +279,9 @@ func (m *Record) IgnoreEmailVisibility(state bool) {
m.ignoreEmailVisibility = state
}
// WithUnkownData toggles the export/serialization of unknown data fields
// WithUnknownData toggles the export/serialization of unknown data fields
// (false by default).
func (m *Record) WithUnkownData(state bool) {
func (m *Record) WithUnknownData(state bool) {
m.exportUnknown = state
}
@@ -324,7 +324,7 @@ func (m *Record) Set(key string, value any) {
}
}
// Get returns a single record model data value for "key".
// Get returns a normalized single record model data value for "key".
func (m *Record) Get(key string) any {
switch key {
case schema.FieldNameId:
@@ -334,11 +334,27 @@ func (m *Record) Get(key string) any {
case schema.FieldNameUpdated:
return m.Updated
default:
if m.data == nil {
return nil
var v any
if m.data != nil {
v = m.data.Get(key)
}
return m.data.Get(key)
// normalize the field value in case it is missing or an incorrect type was set
// to ensure that the DB will always have normalized columns value.
if field := m.Collection().Schema.GetFieldByName(key); field != nil {
v = field.PrepareValue(v)
} else if m.collection.IsAuth() {
switch key {
case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
v = cast.ToBool(v)
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
v, _ = types.ParseDateTime(v)
case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
v = cast.ToString(v)
}
}
return v
}
}
@@ -378,6 +394,56 @@ func (m *Record) GetStringSlice(key string) []string {
return list.ToUniqueStringSlice(m.Get(key))
}
// ExpandedOne retrieves a single relation Record from the already
// loaded expand data of the current model.
//
// If the requested expand relation is multiple, this method returns
// only first available Record from the expanded relation.
//
// Returns nil if there is no such expand relation loaded.
func (m *Record) ExpandedOne(relField string) *Record {
if m.expand == nil {
return nil
}
rel := m.expand.Get(relField)
switch v := rel.(type) {
case *Record:
return v
case []*Record:
if len(v) > 0 {
return v[0]
}
}
return nil
}
// ExpandedAll retrieves a slice of relation Records from the already
// loaded expand data of the current model.
//
// If the requested expand relation is single, this method normalizes
// the return result and will wrap the single model as a slice.
//
// Returns nil slice if there is no such expand relation loaded.
func (m *Record) ExpandedAll(relField string) []*Record {
if m.expand == nil {
return nil
}
rel := m.expand.Get(relField)
switch v := rel.(type) {
case *Record:
return []*Record{v}
case []*Record:
return v
}
return nil
}
// Retrieves the "key" json field value and unmarshals it into "result".
//
// Example
@@ -447,7 +513,8 @@ func (m *Record) ColumnValueMap() map[string]any {
// PublicExport exports only the record fields that are safe to be public.
//
// Fields marked as hidden will be exported only if `m.IgnoreEmailVisibility(true)` is set.
// For auth records, to force the export of the email field you need to set
// `m.IgnoreEmailVisibility(true)`.
func (m *Record) PublicExport() map[string]any {
result := make(map[string]any, len(m.collection.Schema.Fields())+5)
+106 -14
View File
@@ -380,7 +380,7 @@ func TestRecordCleanCopy(t *testing.T) {
// allow the special flags and options to check whether they will be ignored
m.SetExpand(map[string]any{"test": 123})
m.IgnoreEmailVisibility(true)
m.WithUnkownData(true)
m.WithUnknownData(true)
copy := m.CleanCopy()
copyExport, _ := copy.MarshalJSON()
@@ -541,6 +541,70 @@ func TestRecordMergeExpandNilCheck(t *testing.T) {
}
}
func TestRecordExpandedRel(t *testing.T) {
collection := &models.Collection{}
main := models.NewRecord(collection)
single := models.NewRecord(collection)
single.Id = "single"
multiple1 := models.NewRecord(collection)
multiple1.Id = "multiple1"
multiple2 := models.NewRecord(collection)
multiple2.Id = "multiple2"
main.SetExpand(map[string]any{
"single": single,
"multiple": []*models.Record{multiple1, multiple2},
})
if v := main.ExpandedOne("missing"); v != nil {
t.Fatalf("Expected nil, got %v", v)
}
if v := main.ExpandedOne("single"); v == nil || v.Id != "single" {
t.Fatalf("Expected record with id %q, got %v", "single", v)
}
if v := main.ExpandedOne("multiple"); v == nil || v.Id != "multiple1" {
t.Fatalf("Expected record with id %q, got %v", "multiple1", v)
}
}
func TestRecordExpandedAll(t *testing.T) {
collection := &models.Collection{}
main := models.NewRecord(collection)
single := models.NewRecord(collection)
single.Id = "single"
multiple1 := models.NewRecord(collection)
multiple1.Id = "multiple1"
multiple2 := models.NewRecord(collection)
multiple2.Id = "multiple2"
main.SetExpand(map[string]any{
"single": single,
"multiple": []*models.Record{multiple1, multiple2},
})
if v := main.ExpandedAll("missing"); v != nil {
t.Fatalf("Expected nil, got %v", v)
}
if v := main.ExpandedAll("single"); len(v) != 1 || v[0].Id != "single" {
t.Fatalf("Expected [single] slice, got %v", v)
}
if v := main.ExpandedAll("multiple"); len(v) != 2 || v[0].Id != "multiple1" || v[1].Id != "multiple2" {
t.Fatalf("Expected [multiple1, multiple2] slice, got %v", v)
}
}
func TestRecordSchemaData(t *testing.T) {
collection := &models.Collection{
Type: models.CollectionTypeAuth,
@@ -665,6 +729,22 @@ func TestRecordSetAndGet(t *testing.T) {
Name: "field2",
Type: schema.FieldTypeNumber,
},
// fields that are not explicitly set to check
// the default retrieval value (single and multiple)
&schema.SchemaField{
Name: "field3",
Type: schema.FieldTypeBool,
},
&schema.SchemaField{
Name: "field4",
Type: schema.FieldTypeSelect,
Options: &schema.SelectOptions{MaxSelect: 2},
},
&schema.SchemaField{
Name: "field5",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
},
),
}
@@ -677,28 +757,40 @@ func TestRecordSetAndGet(t *testing.T) {
m.Set("unknown", 456) // undefined fields are allowed but not exported by default
m.Set("expand", map[string]any{"test": 123}) // should store the value in m.expand
if m.Get("id") != "test_id" {
t.Fatalf("Expected id %q, got %q", "test_id", m.Get("id"))
if v := m.Get("id"); v != "test_id" {
t.Fatalf("Expected id %q, got %q", "test_id", v)
}
if m.GetString("created") != "2022-09-15 00:00:00.123Z" {
t.Fatalf("Expected created %q, got %q", "2022-09-15 00:00:00.123Z", m.GetString("created"))
if v := m.GetString("created"); v != "2022-09-15 00:00:00.123Z" {
t.Fatalf("Expected created %q, got %q", "2022-09-15 00:00:00.123Z", v)
}
if m.GetString("updated") != "" {
t.Fatalf("Expected updated to be empty, got %q", m.GetString("updated"))
if v := m.GetString("updated"); v != "" {
t.Fatalf("Expected updated to be empty, got %q", v)
}
if m.Get("field1") != "123" {
t.Fatalf("Expected field1 %q, got %v", "123", m.Get("field1"))
if v, ok := m.Get("field1").(string); !ok || v != "123" {
t.Fatalf("Expected field1 %#v, got %#v", "123", m.Get("field1"))
}
if m.Get("field2") != 0.0 {
t.Fatalf("Expected field2 %v, got %v", 0.0, m.Get("field2"))
if v, ok := m.Get("field2").(float64); !ok || v != 0.0 {
t.Fatalf("Expected field2 %#v, got %#v", 0.0, m.Get("field2"))
}
if m.Get("unknown") != 456 {
t.Fatalf("Expected unknown %v, got %v", 456, m.Get("unknown"))
if v, ok := m.Get("field3").(bool); !ok || v != false {
t.Fatalf("Expected field3 %#v, got %#v", false, m.Get("field3"))
}
if v, ok := m.Get("field4").([]string); !ok || len(v) != 0 {
t.Fatalf("Expected field4 %#v, got %#v", "[]", m.Get("field4"))
}
if v, ok := m.Get("field5").(string); !ok || len(v) != 0 {
t.Fatalf("Expected field5 %#v, got %#v", "", m.Get("field5"))
}
if v := m.Get("unknown"); v != 456 {
t.Fatalf("Expected unknown %v, got %v", 456, v)
}
if m.Expand()["test"] != 123 {
@@ -1343,7 +1435,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
m.Load(data)
m.IgnoreEmailVisibility(s.exportHidden)
m.WithUnkownData(s.exportUnknown)
m.WithUnknownData(s.exportUnknown)
exportResult, err := json.Marshal(m.PublicExport())
if err != nil {
@@ -6,19 +6,19 @@ import (
"github.com/pocketbase/pocketbase/models/schema"
)
// RequestData defines a HTTP request data struct, usually used
// RequestInfo defines a HTTP request data struct, usually used
// as part of the `@request.*` filter resolver.
type RequestData struct {
Method string `json:"method"`
type RequestInfo struct {
Query map[string]any `json:"query"`
Data map[string]any `json:"data"`
Headers map[string]any `json:"headers"`
AuthRecord *Record `json:"authRecord"`
Admin *Admin `json:"admin"`
Method string `json:"method"`
}
// HasModifierDataKeys loosely checks if the current struct has any modifier Data keys.
func (r *RequestData) HasModifierDataKeys() bool {
func (r *RequestInfo) HasModifierDataKeys() bool {
allModifiers := schema.FieldValueModifiers()
for key := range r.Data {
@@ -6,20 +6,20 @@ import (
"github.com/pocketbase/pocketbase/models"
)
func TestRequestDataHasModifierDataKeys(t *testing.T) {
func TestRequestInfoHasModifierDataKeys(t *testing.T) {
scenarios := []struct {
name string
requestData *models.RequestData
requestInfo *models.RequestInfo
expected bool
}{
{
"empty",
&models.RequestData{},
&models.RequestInfo{},
false,
},
{
"Data with regular fields",
&models.RequestData{
&models.RequestInfo{
Query: map[string]any{"data+": "demo"}, // should be ignored
Data: map[string]any{"a": 123, "b": "test", "c.d": false},
},
@@ -27,21 +27,21 @@ func TestRequestDataHasModifierDataKeys(t *testing.T) {
},
{
"Data with +modifier fields",
&models.RequestData{
&models.RequestInfo{
Data: map[string]any{"a+": 123, "b": "test", "c.d": false},
},
true,
},
{
"Data with -modifier fields",
&models.RequestData{
&models.RequestInfo{
Data: map[string]any{"a": 123, "b-": "test", "c.d": false},
},
true,
},
{
"Data with mixed modifier fields",
&models.RequestData{
&models.RequestInfo{
Data: map[string]any{"a": 123, "b-": "test", "c.d+": false},
},
true,
@@ -49,7 +49,7 @@ func TestRequestDataHasModifierDataKeys(t *testing.T) {
}
for _, s := range scenarios {
result := s.requestData.HasModifierDataKeys()
result := s.requestInfo.HasModifierDataKeys()
if result != s.expected {
t.Fatalf("[%s] Expected %v, got %v", s.name, s.expected, result)
+40 -9
View File
@@ -132,6 +132,10 @@ type SchemaField struct {
Type string `form:"type" json:"type"`
Required bool `form:"required" json:"required"`
// Presentable indicates whether the field is suitable for
// visualization purposes (eg. in the Admin UI relation views).
Presentable bool `form:"presentable" json:"presentable"`
// Deprecated: This field is no-op and will be removed in future versions.
// Please use the collection.Indexes field to define a unique constraint.
Unique bool `form:"unique" json:"unique"`
@@ -143,13 +147,17 @@ type SchemaField struct {
func (f *SchemaField) ColDefinition() string {
switch f.Type {
case FieldTypeNumber:
return "NUMERIC DEFAULT 0"
return "NUMERIC DEFAULT 0 NOT NULL"
case FieldTypeBool:
return "BOOLEAN DEFAULT FALSE"
return "BOOLEAN DEFAULT FALSE NOT NULL"
case FieldTypeJson:
return "JSON DEFAULT NULL"
default:
return "TEXT DEFAULT ''"
if opt, ok := f.Options.(MultiValuer); ok && opt.IsMultiple() {
return "JSON DEFAULT '[]' NOT NULL"
}
return "TEXT DEFAULT '' NOT NULL"
}
}
@@ -190,7 +198,7 @@ func (f SchemaField) Validate() error {
excludeNames := BaseModelFieldNames()
// exclude special filter literals
excludeNames = append(excludeNames, "null", "true", "false")
excludeNames = append(excludeNames, "null", "true", "false", "_rowid_")
// exclude system literals
excludeNames = append(excludeNames, SystemFieldNames()...)
@@ -448,21 +456,36 @@ func (o *TextOptions) checkRegex(value any) error {
// -------------------------------------------------------------------
type NumberOptions struct {
Min *float64 `form:"min" json:"min"`
Max *float64 `form:"max" json:"max"`
Min *float64 `form:"min" json:"min"`
Max *float64 `form:"max" json:"max"`
NoDecimal bool `form:"noDecimal" json:"noDecimal"`
}
func (o NumberOptions) Validate() error {
var maxRules []validation.Rule
if o.Min != nil && o.Max != nil {
maxRules = append(maxRules, validation.Min(*o.Min))
maxRules = append(maxRules, validation.Min(*o.Min), validation.By(o.checkNoDecimal))
}
return validation.ValidateStruct(&o,
validation.Field(&o.Min, validation.By(o.checkNoDecimal)),
validation.Field(&o.Max, maxRules...),
)
}
func (o *NumberOptions) checkNoDecimal(value any) error {
v, _ := value.(*float64)
if v == nil || !o.NoDecimal {
return nil // nothing to check
}
if *v != float64(int64(*v)) {
return validation.NewError("validation_no_decimal_constraint", "Decimal numbers are not allowed.")
}
return nil
}
// -------------------------------------------------------------------
type BoolOptions struct {
@@ -515,6 +538,12 @@ func (o UrlOptions) Validate() error {
// -------------------------------------------------------------------
type EditorOptions struct {
// ConvertUrls is usually used to instruct the editor whether to
// apply url conversion (eg. stripping the domain name in case the
// urls are using the same domain as the one where the editor is loaded).
//
// (see also https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls)
ConvertUrls bool `form:"convertUrls" json:"convertUrls"`
}
func (o EditorOptions) Validate() error {
@@ -597,6 +626,7 @@ type FileOptions struct {
MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
Thumbs []string `form:"thumbs" json:"thumbs"`
Protected bool `form:"protected" json:"protected"`
}
func (o FileOptions) Validate() error {
@@ -640,7 +670,8 @@ type RelationOptions struct {
// If nil no limits are applied.
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
// DisplayFields is optional slice of collection field names used for UI purposes.
// Deprecated: This field is no-op and will be removed in future versions.
// Instead use the individula SchemaField.Presentable option for each field in the relation collection.
DisplayFields []string `form:"displayFields" json:"displayFields"`
}
@@ -652,7 +683,7 @@ func (o RelationOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(&o.CollectionId, validation.Required),
validation.Field(&o.MinSelect, validation.NilOrNotEmpty, validation.Min(1)),
validation.Field(&o.MinSelect, validation.Min(0)),
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(minVal)),
)
}

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