Compare commits

...
304 Commits
Author SHA1 Message Date
Gani Georgiev 869d1cbcf7 fixed record delete test expected query strings 2022-12-09 19:15:24 +02:00
Gani Georgiev 451611776e updated ui/dist 2022-12-09 19:12:24 +02:00
Gani Georgiev 9c7f48a66e [#1220] fixed field column name conflict on cascade record deletion 2022-12-09 19:09:43 +02:00
Gani Georgiev 59b41c8202 updated changelog 2022-12-09 12:06:37 +02:00
Gani Georgiev 94658712c6 [#1219] fixed events when manual editing the datetime field and added clear button 2022-12-09 12:05:25 +02:00
Gani Georgiev 2c4ac070a3 fixed record delete tests 2022-12-09 11:07:43 +02:00
Gani Georgiev e206e303ca updated changelog 2022-12-09 10:27:54 +02:00
Gani Georgiev e60f470188 call root record delete first 2022-12-09 01:50:37 +02:00
Gani Georgiev 9cf5e28700 replaced QueryString() with QueryParams().Encode() 2022-12-08 13:35:56 +02:00
Gani Georgiev 7aefcd9bf6 updated changelog 2022-12-08 12:18:17 +02:00
Gani Georgiev 693954cdcd [#1187] added Dao query semaphore and base fail/retry 2022-12-08 10:40:42 +02:00
Gani Georgiev 355f7053fd [#1187] move file upload and delete out of the record save transaction 2022-12-06 12:26:29 +02:00
Gani Georgiev 808f5054d0 updated go deps 2022-12-06 07:21:26 +02:00
Gani Georgiev f1d546c845 truncate the original filename metadata 2022-12-06 07:17:59 +02:00
Gani Georgiev dba66d4da1 updated changelog and ui/dist 2022-12-06 00:32:10 +02:00
Gani Georgiev 4070a11660 updated changelog 2022-12-05 15:34:18 +02:00
Gani Georgiev 45b72dd6b3 use the executable name in the cmd usage doc 2022-12-05 15:24:02 +02:00
Gani Georgiev 599c542c5a store the original uploaded file name as metadata 2022-12-05 14:28:28 +02:00
Gani Georgiev b8cd686b32 updated automigrate templates, added js bindings tests and updated models IsNew behavior 2022-12-05 13:57:09 +02:00
Gani Georgiev 604009bd10 [#468] added record auth verification, password reset and email change request event hooks 2022-12-03 14:50:12 +02:00
Gani Georgiev 02f72638b8 added error event hooks 2022-12-02 16:36:15 +02:00
Gani Georgiev 23fbfab63a [#468] added additional realtime events 2022-12-02 14:25:55 +02:00
Gani Georgiev 98cc8e2aee added empty migrations template test and removed publicdir plugin 2022-12-02 12:36:57 +02:00
Gani Georgiev 04018f931b added record.OriginalCopy() to return a record model copy with the original/initial data 2022-12-02 11:37:11 +02:00
Gani Georgiev d2028143df skip empty automigrate templates 2022-12-02 11:36:13 +02:00
Gani Georgiev 6400924d29 updated ui/dist 2022-12-01 20:26:11 +02:00
Gani Georgiev 44a69eb4ba skip triggering the before hooks on record delete retry 2022-12-01 19:00:38 +02:00
Gani Georgiev 0fa5edb0b1 added custom goja field mapper to handle all caps identifiers and allowed errors unwrapping 2022-11-30 17:23:41 +02:00
Gani Georgiev 799e1d96f8 [#654] updated OAuth2 providers to return the access token and raw user data 2022-11-30 15:16:09 +02:00
Gani Georgiev 9ba710cdc5 removed unused automigrate methods and updated changelog 2022-11-29 22:28:38 +02:00
Gani Georgiev a4953cbb4e optimized record references lookups 2022-11-29 22:28:23 +02:00
Gani Georgiev 647158f62d [#1138] fixed concurrent cascade update/delete and added fail/retry because of SQLITE_BUSY 2022-11-29 18:14:09 +02:00
Gani Georgiev 2deca759fa added multipart range test 2022-11-29 18:12:40 +02:00
Gani Georgiev bd65125744 [#1125] added support for partial/range file requests 2022-11-29 15:52:37 +02:00
Gani Georgiev 328b99a690 updated WIP:v0.9.0 changelog 2022-11-28 21:56:49 +02:00
Gani Georgiev 33539452de added automigrate tests 2022-11-28 19:59:17 +02:00
Gani Georgiev c6f03cda43 updated go deps 2022-11-27 23:24:51 +02:00
Gani Georgiev 2d3531dd66 removed git path lookups and updated examples/base .gitignore 2022-11-27 23:21:42 +02:00
Gani Georgiev 7ac3a74440 refactored automigrate to be more granular 2022-11-27 23:01:27 +02:00
Gani Georgiev 3bce173748 fixed typo 2022-11-26 22:59:37 +02:00
Gani Georgiev b024737ec8 updated ui/dist 2022-11-26 22:37:14 +02:00
Gani Georgiev 675d459137 tweaked automigrate to check for git status and extracted the base flags from the plugins 2022-11-26 22:33:27 +02:00
Gani Georgiev 8c9b657132 moved settings under models and added settings dao helpers 2022-11-26 14:42:45 +02:00
Gani Georgiev d8963c6fc3 added plugins subpackage and added basic support for js migrations 2022-11-26 09:06:09 +02:00
Gani Georgiev 3e1a19685b [#1069] added default Message-ID and more options to customize the mail message 2022-11-21 17:51:44 +02:00
Gani Georgiev c4a660d2d2 [#1079] preserve new field options on drag&drop 2022-11-21 17:51:01 +02:00
Gani Georgiev c12c873a65 gitignore ui/.env.local and ui/.env.*.local files 2022-11-19 12:40:57 +02:00
Gani Georgiev 550260b381 updated dependencies 2022-11-19 00:38:05 +02:00
Gani Georgiev b9922e4843 updated ui/dist 2022-11-18 23:33:13 +02:00
Gani Georgiev 3c3a61c457 added autocomplete keys refresh debounce 2022-11-18 14:48:57 +02:00
Gani Georgiev aed8367231 fixed autocomplete base collection keys caching 2022-11-18 13:32:32 +02:00
Gani Georgiev 341bcc4a0e skip number validator on zero-default 2022-11-17 22:06:31 +02:00
Gani Georgiev 3b9a9df171 don't resolve request and indirect collection keys if disabled 2022-11-17 19:03:31 +02:00
Gani Georgiev a230cc1719 [#1053] improved filter autocomplete performance 2022-11-17 18:59:25 +02:00
Gani Georgiev 7dee9d5cc4 [#1047] added .jfif to the image extensions list 2022-11-17 14:43:10 +02:00
Gani Georgiev 0b54d1736e modify a clone request data when resolving the auth record response 2022-11-17 14:27:54 +02:00
Gani Georgiev 39408f135b [#943] exposed apis.EnrichRecord and apis.EnrichRecords 2022-11-17 14:18:11 +02:00
Gani Georgiev 6e9cf986c5 [#872] changed the schema required validator to be optional for auth collections 2022-11-16 15:13:04 +02:00
Gani Georgiev 4528f075dc fixed auth collection rule check validator on create 2022-11-15 15:06:46 +02:00
Gani Georgiev f3566149b8 [#1030] fixed auth collection rules validator 2022-11-15 12:03:12 +02:00
Gani Georgiev 9322b13d15 [#1028] added case insensitive collection name lookup 2022-11-15 00:54:29 +02:00
Gani Georgiev 77d295730e changed the hook func argument to e for more consistent autocomplete 2022-11-14 19:30:13 +02:00
Gani Georgiev a998618d75 updated godoc comment 2022-11-14 14:43:20 +02:00
Gani Georgiev 4c096fd745 [#970] added Twitch OAuth2 provider 2022-11-13 14:20:11 +02:00
Gani Georgiev c95e50c8a5 updated the oauth2 providers to use the existing oauth2 endpoints and removed the email from spotify 2022-11-13 13:25:24 +02:00
Gani Georgiev bac5d76725 updated ui/dist 2022-11-13 13:09:32 +02:00
Gani Georgiev 50fce1f3cf [#979] added Kakao OAuth2 provider 2022-11-13 13:05:06 +02:00
Gani Georgiev 521df149a2 updated db pool limits, added logs VACUUM, updated api docs 2022-11-13 00:38:18 +02:00
Gani Georgiev 39dc1d2795 updated api preview docs 2022-11-08 20:53:31 +02:00
Gani Georgiev 3d14addfef changed the return result of the confirm api actions 2022-11-08 18:12:37 +02:00
Gani Georgiev bc519231d9 added wildcard realtime topic support 2022-11-08 12:57:50 +02:00
Gani Georgiev b1c7a012c5 [#961] updated min username length and added tests 2022-11-08 12:55:18 +02:00
Gani Georgiev 01814067b1 updated api preview dummy record date field layout 2022-11-06 15:48:27 +02:00
Gani Georgiev 7225b380d5 fixed PseudorandomString 2022-11-06 15:35:43 +02:00
Gani Georgiev fa791b7e96 init pseudorandom seed 2022-11-06 15:30:56 +02:00
Gani Georgiev 0ff5606d80 renamed PseudoRandom to Pseudorandom 2022-11-06 15:28:41 +02:00
Gani Georgiev 4cddb6b5cb added pseudorandom generator 2022-11-06 15:26:34 +02:00
Gani Georgiev 46dc6cc47c added record.PasswordHash() getter 2022-11-06 11:04:04 +02:00
Gani Georgiev 65693d1916 updated the random generator for more even distribution 2022-11-05 17:55:32 +02:00
Gani Georgiev a2abeb872a added option to toggle the system fields visibility 2022-11-05 13:22:08 +02:00
Gani Georgiev 6115fb3874 updated go deps and loaded auth collection fields for autocomplete 2022-11-04 15:55:25 +02:00
Gani Georgiev cb6ffc1e7b use param.Value when comparing with the refreshed settings state 2022-11-03 15:44:13 +02:00
Gani Georgiev 152f6a9d1f updated app.RefreshSettings and added more tests 2022-11-03 15:01:26 +02:00
Gani Georgiev fe656a1c42 updated api preview docs 2022-11-03 11:36:59 +02:00
Gani Georgiev 7e7e2e98a4 updated go action min version 2022-11-02 22:08:30 +02:00
Gani Georgiev 099230a552 added missing time import 2022-11-02 21:52:47 +02:00
Gani Georgiev 5e0718176d added db pool size limits and update the min go release action version to 1.19.3 2022-11-02 21:44:23 +02:00
Gani Georgiev 1a28532546 updated db pool limits 2022-11-01 22:02:38 +02:00
Gani Georgiev 8bb03d2e6b [#875] reordered the busy_timeout pragma and added a fixed/capped connections pool for the nocgo sqlite driver 2022-11-01 20:29:07 +02:00
Olle Månsson 639522b142 [#887] added Spotify OAuth2 provider 2022-11-01 17:06:06 +02:00
Gani Georgiev 9cef6ebd82 removed DrySubmit form errors wrapping and added more api tests 2022-11-01 00:28:33 +02:00
Gani Georgiev 5298543ce4 [#746] added microsoft oauth2 provider 2022-10-31 21:18:00 +02:00
Gani Georgiev bcb9c22685 added pre-release note 2022-10-30 10:33:42 +02:00
Gani Georgiev 90dba45d7c initial v0.8 pre-release 2022-10-30 10:28:14 +02:00
Jan Lauber 9cbb2e750e [#794] fixed comment typos 2022-10-17 20:17:44 +03:00
Piotr Rogowski 6385c5e646 [#789] fixed typo in realtime debug log 2022-10-17 08:21:56 +03:00
Gani Georgiev 32393990bb preserve records pagination on delete/update and fix reactivity regression 2022-10-04 22:42:51 +03:00
Gani Georgiev 838ed661ce fixed formatted date reactivity 2022-10-02 23:56:24 +03:00
Gani Georgiev d84e57174b updated code comments formatting 2022-10-02 13:38:59 +03:00
Gani Georgiev a6cafd1ed7 [#677] unset the X-Frame-Options when serving static files to allow files embedding 2022-10-02 13:28:33 +03:00
Gani Georgiev b0db2399b8 updated filesystem tests 2022-10-02 12:38:14 +03:00
Gani Georgiev 81d0af6e80 [#693] added media-src to the default files CSP 2022-10-02 12:33:31 +03:00
Gani Georgiev 5f5f0ed793 added Open Collective to the funding options 2022-09-30 11:39:38 +03:00
Gani Georgiev 353248c34a updated ui/dist 2022-09-29 12:54:58 +03:00
Gani Georgiev 93d48a85ac added fallback handling when both contains operands are table columns 2022-09-29 12:33:53 +03:00
Gani Georgiev b84930f21a records listing optimizations 2022-09-28 22:17:24 +03:00
Rohan Verma 3cbab96f51 [#661] serve css files with text/css content-type
Currently, css files are served as text/plain by the server. It is not
trivial to detect css file types similar to the issue with svg files.

When the css files are served as text/plain instead of
text/css they become unusable as stylesheets in the browser when served
via the api.

In this commit we generalize the svg detection to also detect css files
and serve specific extensions with their respective mimetypes.
2022-09-28 21:25:50 +03:00
Gani Georgiev 6c005c4a9a remove OrderBy nil variadic argument 2022-09-22 20:35:20 +03:00
Gani Georgiev ccc3707fb6 replaced empty slice literal with nil 2022-09-22 20:23:50 +03:00
Gani Georgiev 3d36ff7e96 unset ORDER BY for search count queries 2022-09-22 20:18:17 +03:00
Gani Georgiev 0b2eb24c6f updated go deps 2022-09-21 21:29:23 +03:00
Gani Georgiev 954067860c [#590] fixed realtime events bind order by adding hooks.PreAdd 2022-09-21 14:41:20 +03:00
Gani Georgiev 9a8c9dd115 [#586] fixed multiple nested relation expansions with shared base path 2022-09-21 13:34:34 +03:00
Aaron Schmied a1ad5004f8 [#585] respect the EXIF orientation tag when generating thumbs
@see: https://github.com/disintegration/imaging#incorrect-image-orientation-after-processing-eg-an-image-appears-rotated-after-resizing
2022-09-21 13:13:26 +03:00
Gani Georgiev 7006e1f5d7 [#567] resolve the direct user profile fields from the profiles table and not from the static auth model 2022-09-20 11:20:30 +03:00
Gani Georgiev 8be8f3f3cb updated search provider tests 2022-09-18 08:49:51 +03:00
Gani Georgiev 00fd007d50 raised MaxPerPage limit to 400 2022-09-18 08:41:42 +03:00
Gani Georgiev 96bfc69c8e updated ui/dist 2022-09-18 01:57:18 +03:00
Gani Georgiev e542b0d8c5 include only the words selection keymap for code inputs 2022-09-18 01:55:33 +03:00
Gani Georgiev 9814dda8e4 [#478] load selected relation items before the other options 2022-09-18 01:18:54 +03:00
Gani Georgiev 978fdd3ce7 [#478] preserve multiple selection order 2022-09-18 00:29:32 +03:00
Gani Georgiev 9cf89870e7 [#519] improved query performance for relations lookup 2022-09-17 22:55:56 +03:00
Gani Georgiev b8c54568e3 fixed readme typo 2022-09-16 13:24:02 +03:00
Gani Georgiev 843bbf99cc added note about pull requests 2022-09-16 13:06:28 +03:00
Gani Georgiev daffb0f86e [#488] added X-Accel-Buffering:no sse header 2022-09-16 11:19:31 +03:00
Gani Georgiev 1f5c3328e5 [#470] added --pbPublic flag 2022-09-16 11:18:15 +03:00
Gani Georgiev ed5f3b86f5 [#470] don't rely on the cwd and look for pb_public relative to pb_data 2022-09-15 22:52:24 +03:00
Travis Ray 1ba2d14231 [#446] Fixed spelling error on Realtime API page 2022-09-14 21:31:12 +03:00
Gani Georgiev 6cda610ede updated ui deps and generated ui/dist 2022-09-14 20:53:09 +03:00
Gani Georgiev 2fa5233fa6 [#440] try to use the original image format when creating thumbs 2022-09-14 17:12:47 +03:00
Gani Georgiev 030dfc2690 updated ui/dist 2022-09-10 22:54:26 +03:00
Gani Georgiev 8c11e2ef01 [#409] added pocketbase.NewWithConfig factory 2022-09-10 22:53:17 +03:00
Gani Georgiev 4b64e0910b removed commented pb.Bootstrap() code 2022-09-09 14:54:19 +03:00
Gani Georgiev 4a6bc453de updated ui deps and skip creating pb_data on --version or --help execution 2022-09-09 13:58:29 +03:00
Gani Georgiev 96d09a30c4 [#405] updated Google OAuth2 userinfo response data 2022-09-09 09:12:34 +03:00
Gani Georgiev a0d7f23d77 [#396] normalized tests.ApiScenario.TestAppFactory declaration 2022-09-07 20:51:03 +03:00
Gani Georgiev 74108d84ca [#396] renamed tests.CloneIntoTempDir to tests.TempDirClone 2022-09-07 20:34:18 +03:00
Gani Georgiev 4bc28abac4 [#396] updated tests helpers 2022-09-07 20:31:05 +03:00
Gani Georgiev b79a7982bf [#385] added username to the OAuth2 AuthUser model 2022-09-05 16:15:27 +03:00
Gani Georgiev b717896232 [#390] serve the mimetype detected during upload 2022-09-05 15:46:40 +03:00
Gani Georgiev 9d53aec9ee [#375] fixed windows console color output 2022-09-02 14:42:22 +03:00
Gani Georgiev 9d30ca81cb fixed comment typo 2022-09-02 12:45:59 +03:00
Gani Georgiev 93b3788448 added additional check for empty ExternalAuth data in case the provider api changes 2022-09-02 12:05:00 +03:00
Gani Georgiev 0ac4c9e1fc updated sdk 2022-09-02 10:12:36 +03:00
Gani Georgiev df1a947b61 updated response messages and rebuilt ui/dist 2022-09-02 10:00:36 +03:00
Gani Georgiev 06a7f1af05 replaced MX email validator with email format only 2022-09-01 17:08:55 +03:00
Gani Georgiev 07ac5bf6a2 [#33] added Twitter OAuth2 provider 2022-09-01 16:46:06 +03:00
Gani Georgiev f56c52a1f7 share auth providers UI configurations 2022-09-01 15:49:00 +03:00
Gani Georgiev f0b57c6b91 [#276] added option to list and unlink external user auth relations 2022-09-01 12:22:59 +03:00
Gani Georgiev f61d0ec6f7 synced with master 2022-08-31 21:33:17 +03:00
Gani Georgiev 4e78f1e4ad updated ui/dist 2022-08-31 21:01:12 +03:00
Gani Georgiev 235670fae8 [#367] fixed wildcard multiple file upload deletion and added support to delete a single file by its name 2022-08-31 20:33:07 +03:00
Gani Georgiev f5ff7193a9 [#276] added support for linking external auths by provider id 2022-08-31 13:38:31 +03:00
Gani Georgiev 9fe94f5c7d [#351] improved mimetype sniffing 2022-08-26 07:00:22 +03:00
Gani Georgiev 0f9ddbf7ec added auto html to plain text mail generation 2022-08-26 06:46:34 +03:00
Gani Georgiev f14105b04a synced with master 2022-08-25 16:50:28 +03:00
Gani Georgiev 3bfa0b6dd4 updated ui/dist 2022-08-25 11:13:52 +03:00
Gani Georgiev 9e3c59f966 reverted changes to the mailer To: address format 2022-08-25 10:59:55 +03:00
Gani Georgiev a908d20dcd increased max allowed token duration 2022-08-25 10:58:15 +03:00
Gani Georgiev 49b084cf50 [#335] added Discord OAuth2 provider 2022-08-21 20:04:38 +03:00
Gani Georgiev 0b8c7f6883 fixed typo and removed unusued import 2022-08-21 14:47:33 +03:00
Gani Georgiev 587cfc335c [#75] added option to test s3 connection and send test emails 2022-08-21 14:30:36 +03:00
Gani Georgiev 3f4f4cf031 [#282] reversed the X-Forwarded-For ips iteration 2022-08-20 08:01:54 +03:00
Gani Georgiev d4f160d959 updated the user and admin login email validator to check only the string format 2022-08-20 07:58:42 +03:00
Gani Georgiev 07cd758112 [#282] fixed "real" user ip extraction 2022-08-20 07:57:17 +03:00
Gani Georgiev beb8e7924d [#282] fixed X-Forward-For ip extraction 2022-08-20 05:56:56 +03:00
Gani Georgiev 72fdf0d116 updated test data.db 2022-08-18 21:05:17 +03:00
Gani Georgiev 7e14ea7cfb [#210] change the uploaded filename strategy to include the original filename 2022-08-18 20:44:29 +03:00
Gani Georgiev 25c4db7a30 updated ui/dist with older sdk version 2022-08-18 20:32:07 +03:00
Gani Georgiev e86b1f54a0 updated ui/dist 2022-08-18 20:28:20 +03:00
Gani Georgiev 7be389704d added hideControls setting 2022-08-18 18:45:27 +03:00
Gani Georgiev cfaff31d97 [#282] added the "real" user ip to the logs 2022-08-18 15:27:45 +03:00
Gani Georgiev 9bf66be870 fixed typos 2022-08-17 22:32:10 +03:00
Gani Georgiev efda3d5a0b [#87] added additional thumb resizers 2022-08-17 22:29:47 +03:00
Gani Georgiev a516435f2e fixed settings clone test 2022-08-16 13:37:59 +03:00
Gani Georgiev ccd010c490 simplified mail settings ui 2022-08-16 13:32:32 +03:00
Gani Georgiev 456ced75ce [#197] added now datetime filter constant 2022-08-15 22:38:17 +03:00
Gani Georgiev b7d32c23aa updated ui/dist 2022-08-15 21:42:01 +03:00
Gani Georgiev 04ddae46c9 updated SMTP recommendation 2022-08-15 21:31:25 +03:00
Gani Georgiev 7d10d20de1 [#275] added support to customize the default user email templates from the Admin UI 2022-08-14 19:30:45 +03:00
Gani Georgiev 1de56d3d9e [#302] fixed admin reset password email link 2022-08-12 17:08:36 +03:00
Gani Georgiev 80f710731c updated vite dependency 2022-08-12 12:30:11 +03:00
Gani Georgiev bd76130cd8 [#] added missing import in the api preview for deleting records 2022-08-12 12:20:56 +03:00
Gani Georgiev d60dd13581 [#294] added additional inline serving mime types 2022-08-11 20:09:26 +03:00
Gani Georgiev 19d4fc04c1 fixed UI import preview when replacing ids with missing old fields 2022-08-11 15:59:14 +03:00
Gani Georgiev a7b29c1961 updated dependencies and rebuilt ui/dist 2022-08-11 11:33:01 +03:00
Gani Georgiev 147344546b added custom insertion id regex check 2022-08-11 10:29:01 +03:00
Gani Georgiev ff935a39a1 removed spacing 2022-08-11 08:13:07 +03:00
Gani Georgiev 2cce0b17b0 synced with master 2022-08-10 20:53:13 +03:00
Gani Georgiev 5f25572e95 updated ui/dist 2022-08-10 20:46:45 +03:00
Gani Georgiev dd5c9ccce8 changed btns transition to local 2022-08-10 20:45:32 +03:00
Olle MånssonandOlle Månsson da29141248 [#288] added info about the default 100x100 thumb size in the field tooltip
Co-authored-by: Olle Månsson <olle.mansson@zenseact.com>
2022-08-10 20:40:52 +03:00
Gani Georgiev 7f6049ebd6 updated test db and ui/dist 2022-08-10 20:37:41 +03:00
Gani Georgiev 119e1fb3f2 use fixed ids in the default profiles system collections migration 2022-08-10 19:08:29 +03:00
Gani Georgiev c5091898ae added test to test schema field persistence after import 2022-08-10 17:43:55 +03:00
Gani Georgiev 35bd395e55 removed unused lib 2022-08-10 17:43:08 +03:00
Gani Georgiev d56b8fcb90 updated import popup handling and api preview examples 2022-08-10 16:16:59 +03:00
Gani Georgiev 65b830198b test deleteMissing with schema changes 2022-08-10 13:22:27 +03:00
Gani Georgiev ac0c23ff64 fixed list js example 2022-08-09 17:57:32 +03:00
Gani Georgiev a355c8e8a9 removed duplicated method 2022-08-09 17:20:22 +03:00
Gani Georgiev c346dd69a4 updated ui/dist 2022-08-09 17:17:13 +03:00
Gani Georgiev f8f785d6e3 call transaction Dao events only after commit, added totalPages to the search response and updated the tests 2022-08-09 16:20:39 +03:00
Gani Georgiev 8288da8372 added version number in the footer 2022-08-09 16:16:09 +03:00
Gani Georgiev 8b2b26c196 fixed after hooks 2022-08-08 20:14:46 +03:00
Gani Georgiev 8009d37d24 updated tests 2022-08-08 19:16:33 +03:00
Gani Georgiev 6e9d000426 before updateding test data 2022-08-07 20:58:21 +03:00
Gani Georgiev a426484916 added WithConfig factory to all forms 2022-08-07 15:38:21 +03:00
Gani Georgiev b0ca9b2f1b updated export/import form 2022-08-07 11:14:49 +03:00
Gani Georgiev 956263d1fc updated admin and user upsert forms 2022-08-06 22:16:58 +03:00
Gani Georgiev 51d635bc12 fixed popup hide 2022-08-06 18:59:28 +03:00
Gani Georgiev 5fb45a1864 updated CollectionsImport and CollectionUpsert forms 2022-08-06 18:15:18 +03:00
Gani Georgiev 4e58e7ad6a added ImportPopup 2022-08-06 08:03:34 +03:00
Gani Georgiev 93ab5fbea2 added page export and import 2022-08-05 23:25:16 +03:00
Gani Georgiev f459dd8812 import scaffoldings 2022-08-05 06:00:38 +03:00
Gani Georgiev 95f9d685dc updated base model comments 2022-08-03 16:26:36 +03:00
Gani Georgiev bb00f198bc changed btns transition to local 2022-08-03 10:16:13 +03:00
Gani Georgiev 0e1b9a3897 updated go version constraint for the github action 2022-08-02 21:36:36 +03:00
Gani Georgiev e32cf12908 updated ui/dist 2022-08-02 21:32:36 +03:00
Gani Georgiev c152f99793 fixed datepicker clipped input borders 2022-08-02 21:31:42 +03:00
Gani Georgiev f8b7a40837 switch to stable go 1.19.0 2022-08-02 20:57:03 +03:00
Gani Georgiev a049a37624 updated sdk to ^0.3.0 2022-08-02 17:00:14 +03:00
Gani Georgiev 8268c26d8b fixed README typo 2022-08-01 22:30:14 +03:00
Gani Georgiev 6292e6cc2e unify the prod and dev env and use only relative base path ./ in the Admin UI 2022-08-01 20:55:31 +03:00
Gani Georgiev fbeaabf6aa removed no longer needed ui.DistIndexHTML var 2022-08-01 20:53:06 +03:00
Gani Georgiev 30d1b9f358 refactored the admin ui routes registration for better sub-path deployment support 2022-08-01 20:37:51 +03:00
Gani Georgiev 16fa099685 added default admin CSP 2022-08-01 18:10:18 +03:00
Gani Georgiev bb4bebc724 removed box-shadow for image-preview modals 2022-08-01 18:00:06 +03:00
Gani Georgiev 9d0ea7635b [#204] fixed query string parsing 2022-08-01 14:20:21 +03:00
Yin Shanyang d35134e913 [#250] added armv7 on linux as a build target 2022-08-01 10:29:06 +03:00
Gani Georgiev ce8af46fff unified file field styles 2022-07-31 23:28:47 +03:00
Gani Georgiev 87ecb1114c cleaning up no longer needed ui helper methods 2022-07-31 23:21:55 +03:00
Gani Georgiev c070be2c47 [#238] removed implicit select items grouping 2022-07-31 18:52:51 +03:00
Gani Georgiev 4f0041a128 added dart-sdk to the readme 2022-07-31 13:05:05 +03:00
Gani Georgiev 0ac24af7c9 updated ui/dist 2022-07-31 11:51:06 +03:00
Gani Georgiev 96b2c5fedf added Dart to the api preview examples 2022-07-30 21:04:44 +03:00
Gani Georgiev 4019ca5f00 [#223] change the default prod backend url to relative path to support sub-path deployment 2022-07-30 20:59:09 +03:00
Gani Georgiev 20fe3c8c91 added debug log for the invalid uploaded file(s) 2022-07-30 14:11:08 +03:00
Gani Georgiev d87a5e544c updated admin ui dependencies 2022-07-30 08:02:41 +03:00
Gani Georgiev bb527be493 fixed panic on expanding existing byt non-relation type field 2022-07-30 07:58:42 +03:00
Gani Georgiev 9e3b230c8e added debug log for established realtime connection 2022-07-28 08:26:05 +03:00
Gani Georgiev 686198a22e normalize number filter literals
Always cast number literals to provide consistent eq/neq behavior when combined with COALESCE, because '1' = 1 is TRUE but COALESCE('1', '') = COALESCE(1, '') will result to FALSE.
2022-07-28 05:23:58 +03:00
Gani Georgiev 086b992c7d [#228] added target=_blank to the email links 2022-07-26 15:17:10 +03:00
Takeshi Sato 88d8cec3d9 [#207] use read-only scopes for the GitHub OAuth2 provider 2022-07-24 18:04:53 +03:00
Gani Georgiev 7926501649 updated comments and added CSP header check in the tests 2022-07-21 17:22:31 +03:00
Gani Georgiev 4c2cd5a534 simplify the svg extension check 2022-07-21 12:58:06 +03:00
Gani Georgiev 5d8fc939e2 [#164] serve common media files inline and fix svg content-type 2022-07-21 12:56:17 +03:00
Gani Georgiev 1a5180d7d3 added support to filter request.user.profile relation fields 2022-07-20 22:33:24 +03:00
Gani Georgiev 8a08a4764d [#166] fixed api preview examples 2022-07-19 19:41:03 +03:00
Gani Georgiev 66b317f01c run tests before goreleaser 2022-07-19 17:24:58 +03:00
Gani Georgiev ab5a770346 updated tests 2022-07-19 17:23:34 +03:00
Gani Georgiev 65697add43 temporary skip tests in the release action until the async email hooks get fixed 2022-07-19 14:46:02 +03:00
Gani Georgiev 841415f0ff move field delete in a dropdown to prevent accidental clicks 2022-07-19 14:36:35 +03:00
Gani Georgiev f295ce9403 run added password reset and verification sent hooks tests 2022-07-19 14:20:28 +03:00
Gani Georgiev 383b2a1279 [#160] support expand query parameter for create and update requests 2022-07-19 13:31:52 +03:00
Gani Georgiev 73fb12c2bc [#156] added forcePathStyle to the s3 config 2022-07-19 10:45:38 +03:00
Kenneth Lee 571c4dcc8d [#163] fixed migrate down cmd 2022-07-18 23:00:54 +03:00
Gani Georgiev f56adf26f4 added the app name in the document title and fixed the double initial load on records list 2022-07-18 19:44:10 +03:00
Gani Georgiev f8f3ca25ee updated duplicated field name error 2022-07-18 18:10:30 +03:00
Gani Georgiev e01f76d37b allow switching schema field names when renaming fields 2022-07-18 16:26:37 +03:00
Gani Georgiev 47fc9b1066 normalized null handling in search filters 2022-07-18 14:07:25 +03:00
Gani Georgiev eaf08a5c15 [#151] updated the tests to ensure that the cascaded record files are also deleted 2022-07-18 13:19:07 +03:00
Gani Georgiev 8ef3d4e966 [#151] remove files on cascade deletion 2022-07-18 12:04:27 +03:00
Gani Georgiev 04e0cec32c updated ui/dist 2022-07-18 01:06:55 +03:00
Gani Georgiev 9a231ba7b3 applied some of the changes from #149 2022-07-18 01:03:09 +03:00
Gani Georgiev 7f959011b8 moved field option btns inside the panel 2022-07-18 00:55:53 +03:00
Gani Georgiev 994761b728 normalized the caster to return always non-null value and fixed minor ui issues 2022-07-18 00:16:09 +03:00
Gani Georgiev f19b9e3552 commented golangci-lint action as it is not go1.18+ ready 2022-07-17 22:25:28 +03:00
Gani Georgiev 36783b8f04 added golangci-lint action 2022-07-17 22:20:40 +03:00
Gani Georgiev a076cc906f [#147] added CONTRIBUTING.md 2022-07-17 20:33:12 +03:00
Gani Georgiev b1a30f4050 fixed figure tag typo 2022-07-16 11:57:31 +03:00
Gani Georgiev 4506fb17e9 adding video presets to the file options 2022-07-16 11:54:54 +03:00
Gani Georgiev 72f72bc84f added invalid form-group styles 2022-07-16 10:40:04 +03:00
Gani Georgiev 7fd5102fb5 updated EmailOptions domain input tooltips 2022-07-16 10:39:19 +03:00
Gani Georgiev d6bdc51009 changed the storage slide panel transition to local 2022-07-16 10:38:47 +03:00
Gani Georgiev 789373d15d [#100] resets the initial loadList results 2022-07-15 19:37:30 +03:00
Gani Georgiev 2dc000da65 improve error reporting on OAuth2 user profile fetch 2022-07-15 18:52:37 +03:00
Simon Krauter 1095637bcd [#116] fix BaseModel.RefreshUpdated comment 2022-07-14 23:17:53 +03:00
Gani Georgiev c4fcba5210 [#109] prealocated handlers and replaced OnRecordBeforeDeleteRequest with OnModelBeforeDelete 2022-07-14 22:35:57 +03:00
Gani Georgiev d8c8289269 added odd default circle icon size for better visual vertical alignment in firefox 2022-07-14 21:21:47 +03:00
Gani Georgiev dbbfa243bc added new lines for readability and consistency 2022-07-14 20:01:53 +03:00
Valley a16b0c9004 [#114] simplified some code by returning early and added cap for slices 2022-07-14 19:26:08 +03:00
Gani Georgiev 03a7bafa66 use the original vite2 default port and bump the min js-sdk to v0.2.1 2022-07-14 17:09:08 +03:00
Gani Georgiev d129959098 added store.RemoveAll() helper method 2022-07-14 16:39:42 +03:00
Gani Georgiev 6749559a22 log the response error not the handler one 2022-07-14 11:52:35 +03:00
Gani Georgiev 28bc2678e9 minor ui improvements and upgraded dependencies 2022-07-14 09:58:53 +03:00
Gani Georgiev d4202e696b [#99] fix AutoExpandTextArea scrolling 2022-07-13 22:44:59 +03:00
Gani Georgiev 9de3cc99a0 updated api preview body params col sizes 2022-07-13 08:48:38 +03:00
Franco Profeti 111bc59472 [#93] improved the README for no golang devs 2022-07-13 08:12:25 +03:00
Gani Georgiev b2647ebca9 synced refresh button js timeout with the css animation 2022-07-12 20:03:13 +03:00
Gani Georgiev ef226cf9c1 added refresh button to the other listing pages 2022-07-12 19:56:22 +03:00
Valley 63d5a8d633 [#89] simplified some code by returning early and reducing local variable scopes 2022-07-12 19:52:09 +03:00
Gani Georgiev d71c3cd19c updated ui/dist 2022-07-12 18:11:50 +03:00
Cornelius Müller 240bd6790a [#83] added refresh button to page records view 2022-07-12 18:08:57 +03:00
Gani Georgiev 05a4071eba [#80] fixed before hooks data and added optional interceptor to upsert submit 2022-07-12 13:42:06 +03:00
Valley ce857985be [#82] removed version cmd and make use of cobra.Version 2022-07-12 07:57:36 +03:00
Gani Georgiev 320d1482a4 [#77] add warning on storage type change 2022-07-11 22:23:22 +03:00
Gani Georgiev 46399dddac [#78] enable fully qualified URIs for S3 endpoints and improved error reporting when uploading or deleting files 2022-07-11 21:00:17 +03:00
Gani Georgiev 52c288d9db added linter skip comments and removed the Presentator specific inflector.Usernamify 2022-07-11 16:16:01 +03:00
Gani Georgiev ed741662b2 removed the v prefix from the version command 2022-07-11 10:30:34 +03:00
Gani Georgiev 0c14f32822 added additional info about the @expand query parameter 2022-07-11 10:30:20 +03:00
毛亚琛 ec0d3b0d3d [#69] automatically add version information 2022-07-11 10:26:55 +03:00
wenqingl 10d7faea31 [#62] fixed typo in README
defintions -> definitions
2022-07-11 07:36:38 +03:00
Gani Georgiev f62664098e updated ui/dist 2022-07-10 21:14:38 +03:00
毛亚琛 1dfc314bc1 [#60] fix list api docs example 2022-07-10 21:07:04 +03:00
496 changed files with 38672 additions and 15911 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
open_collective: pocketbase
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+9 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '1.19.0-beta.1'
go-version: '>=1.19.4'
# 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,14 @@ jobs:
- name: Build Admin dashboard UI
run: npm --prefix=./ui ci && npm --prefix=./ui run build
# The prebuilt golangci-lint doesn't support go 1.18+ yet
# https://github.com/golangci/golangci-lint/issues/2649
# - name: Run linter
# uses: golangci/golangci-lint-action@v3
- name: Run tests
run: go test ./...
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v3
with:
+5 -4
View File
@@ -6,12 +6,13 @@
# goreleaser builds folder
/.builds/
# examples app directories
pb_data
pb_public
# tests coverage
coverage.out
# plaintask todo files
*.todo
# generated markdown previews
README.html
CHANGELOG.html
LICENSE.html
+12 -3
View File
@@ -9,6 +9,8 @@ before:
builds:
- main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
@@ -18,13 +20,20 @@ builds:
goarch:
- amd64
- arm64
- arm
goarm:
- 7
ignore:
- goos: windows
goarch: arm
- goos: darwin
goarch: arm
release:
draft: true
archives:
-
format: zip
- format: zip
files:
- LICENSE*
- CHANGELOG*
@@ -33,7 +42,7 @@ checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
name_template: '{{ incpatch .Version }}-next'
changelog:
sort: asc
+823
View File
@@ -0,0 +1,823 @@
## v0.9.2
- Fixed field column name conflict on record deletion ([#1220](https://github.com/pocketbase/pocketbase/discussions/1220)).
## v0.9.1
- Moved the record file upload and delete out of the db transaction to minimize the locking times.
- Added `Dao` query semaphore and base fail/retry handling to improve the concurrent writes throughput ([#1187](https://github.com/pocketbase/pocketbase/issues/1187)).
- Fixed records cascade deletion when there are "A<->B" relation references.
- Replaced `c.QueryString()` with `c.QueryParams().Encode()` to allow loading middleware modified query parameters in the default crud actions ([#1210](https://github.com/pocketbase/pocketbase/discussions/1210)).
- Fixed the datetime field not triggerering the `onChange` event on manual field edit and added a "Clear" button ([#1219](https://github.com/pocketbase/pocketbase/issues/1219)).
- Updated the GitHub goreleaser action to use go 1.19.4 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.19.4+label%3ACherryPickApproved).
## v0.9.0
- Fixed concurrent multi-relation cascade update/delete ([#1138](https://github.com/pocketbase/pocketbase/issues/1138)).
- Added the raw OAuth2 user data (`meta.rawUser`) and OAuth2 access token (`meta.accessToken`) to the auth response ([#654](https://github.com/pocketbase/pocketbase/discussions/654)).
- `BaseModel.UnmarkAsNew()` method was renamed to `BaseModel.MarkAsNotNew()`.
Additionally, to simplify the insert model queries with custom IDs, it is no longer required to call `MarkAsNew()` for manually initialized models with set ID since now this is the default state.
When the model is populated with values from the database (eg. after row `Scan`) it will be marked automatically as "not new".
- Added `Record.OriginalCopy()` method that returns a new `Record` copy populated with the initially loaded record data (useful if you want to compare old and new field values).
- Added new event hooks:
```go
app.OnBeforeBootstrap()
app.OnAfterBootstrap()
app.OnBeforeApiError()
app.OnAfterApiError()
app.OnRealtimeDisconnectRequest()
app.OnRealtimeBeforeMessageSend()
app.OnRealtimeAfterMessageSend()
app.OnRecordBeforeRequestPasswordResetRequest()
app.OnRecordAfterRequestPasswordResetRequest()
app.OnRecordBeforeConfirmPasswordResetRequest()
app.OnRecordAfterConfirmPasswordResetRequest()
app.OnRecordBeforeRequestVerificationRequest()
app.OnRecordAfterRequestVerificationRequest()
app.OnRecordBeforeConfirmVerificationRequest()
app.OnRecordAfterConfirmVerificationRequest()
app.OnRecordBeforeRequestEmailChangeRequest()
app.OnRecordAfterRequestEmailChangeRequest()
app.OnRecordBeforeConfirmEmailChangeRequest()
app.OnRecordAfterConfirmEmailChangeRequest()
```
- The original uploaded file name is now stored as metadata under the `original_filename` key. It could be accessed via:
```go
fs, _ := app.NewFilesystem()
defer fs.Close()
attrs, _ := fs.Attributes(fikeKey)
attrs.Metadata["original_name"]
```
- Added support for `Partial/Range` file requests ([#1125](https://github.com/pocketbase/pocketbase/issues/1125)).
This is a minor breaking change if you are using `filesystem.Serve` (eg. as part of a custom `OnFileDownloadRequest` hook):
```go
// old
filesystem.Serve(res, e.ServedPath, e.ServedName)
// new
filesystem.Serve(res, req, e.ServedPath, e.ServedName)
```
- Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)).
This allow writting custom migration scripts such as programmatically creating collections,
initializing default settings, running data imports, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_).
The `migrate` command is available by default for the prebult executable,
but if you use PocketBase as framework you need register it manually:
```go
migrationsDir := "" // default to "pb_migrations" (for js) and "migrations" (for go)
// load js files if you want to allow loading external JavaScript migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
Dir: migrationsDir,
})
// register the `migrate` command
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
TemplateLang: migratecmd.TemplateLangJS, // or migratecmd.TemplateLangGo (default)
Dir: migrationsDir,
Automigrate: true,
})
```
**The refactoring also comes with automigrations support.**
If `Automigrate` is enabled (`true` by default for the prebuilt executable; can be disabled with `--automigrate=0`),
PocketBase will generate seamlessly in the background JS (or Go) migration file with your collection changes.
**The directory with the JS migrations can be committed to your git repo.**
All migrations (Go and JS) are automatically executed on server start.
Also note that the auto generated migrations are granural (in contrast to the `migrate collections` snapshot command)
and allow multiple developers to do changes on the collections independently (even editing the same collection) miniziming the eventual merge conflicts.
Here is a sample JS migration file that will be generated if you for example edit a single collection name:
```js
// pb_migrations/1669663597_updated_posts_old.js
migrate((db) => {
// up
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
collection.name = "posts_new"
return dao.saveCollection(collection)
}, (db) => {
// down
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
collection.name = "posts_old"
return dao.saveCollection(collection)
})
```
- Added new `Dao` helpers to make it easier fetching and updating the app settings from a migration:
```go
dao.FindSettings([optEncryptionKey])
dao.SaveSettings(newSettings, [optEncryptionKey])
```
- Moved `core.Settings` to `models/settings.Settings`:
```
core.Settings{} -> settings.Settings{}
core.NewSettings() -> settings.New()
core.MetaConfig{} -> settings.MetaConfig{}
core.LogsConfig{} -> settings.LogsConfig{}
core.SmtpConfig{} -> settings.SmtpConfig{}
core.S3Config{} -> settings.S3Config{}
core.TokenConfig{} -> settings.TokenConfig{}
core.AuthProviderConfig{} -> settings.AuthProviderConfig{}
```
- Changed the `mailer.Mailer` interface (**minor breaking if you are sending custom emails**):
```go
// Old:
app.NewMailClient().Send(from, to, subject, html, attachments?)
// New:
app.NewMailClient().Send(&mailer.Message{
From: from,
To: to,
Subject: subject,
HTML: html,
Attachments: attachments,
// new configurable fields
Bcc: []string{"bcc1@example.com", "bcc2@example.com"},
Cc: []string{"cc1@example.com", "cc2@example.com"},
Headers: map[string]string{"Custom-Header": "test"},
Text: "custom plain text version",
})
```
The new `*mailer.Message` struct is also now a member of the `MailerRecordEvent` and `MailerAdminEvent` events.
- Other minor UI fixes and improvements
## v0.8.0
**⚠️ This release contains breaking changes and requires some manual migration steps!**
The biggest change is the merge of the `User` models and the `profiles` collection per [#376](https://github.com/pocketbase/pocketbase/issues/376).
There is no longer `user` type field and the users are just an "auth" collection (we now support **collection types**, currently only "base" and "auth").
This should simplify the users management and at the same time allow us to have unlimited multiple "auth" collections each with their own custom fields and authentication options (eg. staff, client, etc.).
In addition to the `Users` and `profiles` merge, this release comes with several other improvements:
- Added indirect expand support [#312](https://github.com/pocketbase/pocketbase/issues/312#issuecomment-1242893496).
- The `json` field type now supports filtering and sorting [#423](https://github.com/pocketbase/pocketbase/issues/423#issuecomment-1258302125).
- The `relation` field now allows unlimitted `maxSelect` (aka. without upper limit).
- Added support for combined email/username + password authentication (see below `authWithPassword()`).
- Added support for full _"manager-subordinate"_ users management, including a special API rule to allow directly changing system fields like email, password, etc. without requiring `oldPassword` or other user verification.
- Enabled OAuth2 account linking on authorized request from the same auth collection (_this is useful for example if the OAuth2 provider doesn't return an email and you want to associate it with the current logged in user_).
- Added option to toggle the record columns visibility from the table listing.
- Added support for collection schema fields reordering.
- Added several new OAuth2 providers (Microsoft Azure AD, Spotify, Twitch, Kakao).
- Improved memory usage on large file uploads [#835](https://github.com/pocketbase/pocketbase/discussions/835).
- More detailed API preview docs and site documentation (the repo is located at https://github.com/pocketbase/site).
- Other minor performance improvements (mostly related to the search apis).
### Migrate from v0.7.x
- **[Data](#data)**
- **[SDKs](#sdks)**
- **[API](#api)**
- **[Internals](#internals)**
#### Data
The merge of users and profiles comes with several required db changes.
The easiest way to apply them is to use the new temporary `upgrade` command:
```sh
# make sure to have a copy of your pb_data in case something fails
cp -r ./pb_data ./pb_data_backup
# run the upgrade command
./pocketbase08 upgrade
# start the application as usual
./pocketbase08 serve
```
The upgrade command:
- Creates a new `users` collection with merged fields from the `_users` table and the `profiles` collection.
The new user records will have the ids from the `profiles` collection.
- Changes all `user` type fields to `relation` and update the references to point to the new user ids.
- Renames all `@collection.profiles.*`, `@request.user.*` and `@request.user.profile.*` filters to `@collection.users.*` and `@request.auth.*`.
- Appends `2` to all **schema field names** and **api filter rules** that conflicts with the new system reserved ones:
```
collectionId => collectionId2
collectionName => collectionName2
expand => expand2
// only for the "profiles" collection fields:
username => username2
email => email2
emailVisibility => emailVisibility2
verified => verified2
tokenKey => tokenKey2
passwordHash => passwordHash2
lastResetSentAt => lastResetSentAt2
lastVerificationSentAt => lastVerificationSentAt2
```
#### SDKs
Please check the individual SDK package changelog and apply the necessary changes in your code:
- [**JavaScript SDK changelog**](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
```sh
npm install pocketbase@latest --save
```
- [**Dart SDK changelog**](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
```sh
dart pub add pocketbase:^0.5.0
# or with Flutter:
flutter pub add pocketbase:^0.5.0
```
#### API
> _**You don't have to read this if you are using an official SDK.**_
- The authorization schema is no longer necessary. Now it is auto detected from the JWT token payload:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>Authorization: Admin TOKEN</td>
<td>Authorization: TOKEN</td>
</tr>
<tr valign="top">
<td>Authorization: User TOKEN</td>
<td>Authorization: TOKEN</td>
</tr>
</table>
- All datetime stings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>2022-01-02 03:04:05.678</td>
<td>2022-01-02 03:04:05.678<strong>Z</strong></td>
</tr>
</table>
- Removed the `@` prefix from the system record fields for easier json parsing:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td><strong>@</strong>collectionId</td>
<td>collectionId</td>
</tr>
<tr valign="top">
<td><strong>@</strong>collectionName</td>
<td>collectionName</td>
</tr>
<tr valign="top">
<td><strong>@</strong>expand</td>
<td>expand</td>
</tr>
</table>
- All users api handlers are moved under `/api/collections/:collection/`:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>
<em>GET /api/<strong>users</strong>/auth-methods</em>
</td>
<td>
<em>GET /api/<strong>collections/:collection</strong>/auth-methods</em>
</td>
</tr>
<tr valign="top">
<td>
<em>POST /api/<strong>users/refresh</strong></em>
</td>
<td>
<em>POST /api/<strong>collections/:collection/auth-refresh</strong></em>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users/auth-via-oauth2</strong></em></td>
<td>
<em>POST /api/<strong>collections/:collection/auth-with-oauth2</strong></em>
<br/>
<em>You can now also pass optional <code>createData</code> object on OAuth2 sign-up.</em>
<br/>
<em>Also please note that now required user/profile fields are properly validated when creating new auth model on OAuth2 sign-up.</em>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users/auth-via-email</strong></em></td>
<td>
<em>POST /api/<strong>collections/:collection/auth-with-password</strong></em>
<br/>
<em>Handles username/email + password authentication.</em>
<br/>
<code>{"identity": "usernameOrEmail", "password": "123456"}</code>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/request-password-reset</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/request-password-reset</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/confirm-password-reset</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/confirm-password-reset</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/request-verification</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/request-verification</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/confirm-verification</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/confirm-verification</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/request-email-change</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/request-email-change</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/confirm-email-change</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/confirm-email-change</em></td>
</tr>
<tr valign="top">
<td><em>GET /api/<strong>users</strong></em></td>
<td><em>GET /api/<strong>collections/:collection/records</strong></em></td>
</tr>
<tr valign="top">
<td><em>GET /api/<strong>users</strong>/:id</em></td>
<td><em>GET /api/<strong>collections/:collection/records</strong>/:id</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong></em></td>
<td><em>POST /api/<strong>collections/:collection/records</strong></em></td>
</tr>
<tr valign="top">
<td><em>PATCH /api/<strong>users</strong>/:id</em></td>
<td><em>PATCH /api/<strong>collections/:collection/records</strong>/:id</em></td>
</tr>
<tr valign="top">
<td><em>DELETE /api/<strong>users</strong>/:id</em></td>
<td><em>DELETE /api/<strong>collections/:collection/records</strong>/:id</em></td>
</tr>
<tr valign="top">
<td><em>GET /api/<strong>users</strong>/:id/external-auths</em></td>
<td><em>GET /api/<strong>collections/:collection/records</strong>/:id/external-auths</em></td>
</tr>
<tr valign="top">
<td><em>DELETE /api/<strong>users</strong>/:id/external-auths/:provider</em></td>
<td><em>DELETE /api/<strong>collections/:collection/records</strong>/:id/external-auths/:provider</em></td>
</tr>
</table>
_In relation to the above changes, the `user` property in the auth response is renamed to `record`._
- The admins api was also updated for consistency with the users api changes:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>
<em>POST /api/admins/<strong>refresh</strong></em>
</td>
<td>
<em>POST /api/admins/<strong>auth-refresh</strong></em>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/admins/<strong>auth-via-email</strong></em></td>
<td>
<em>POST /api/admins/<strong>auth-with-password</strong></em>
<br />
<code>{"identity": "test@example.com", "password": "123456"}</code>
<br />
(notice that the <code>email</code> body field was renamed to <code>identity</code>)
</td>
</tr>
</table>
- To prevent confusion with the auth method responses, the following endpoints now returns 204 with empty body (previously 200 with token and auth model):
```
POST /api/admins/confirm-password-reset
POST /api/collections/:collection/confirm-password-reset
POST /api/collections/:collection/confirm-verification
POST /api/collections/:collection/confirm-email-change
```
- Renamed the "user" related settings fields returned by `GET /api/settings`:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td><strong>user</strong>AuthToken</td>
<td><strong>record</strong>AuthToken</td>
</tr>
<tr valign="top">
<td><strong>user</strong>PasswordResetToken</td>
<td><strong>record</strong>PasswordResetToken</td>
</tr>
<tr valign="top">
<td><strong>user</strong>EmailChangeToken</td>
<td><strong>record</strong>EmailChangeToken</td>
</tr>
<tr valign="top">
<td><strong>user</strong>VerificationToken</td>
<td><strong>record</strong>VerificationToken</td>
</tr>
</table>
#### Internals
> _**You don't have to read this if you are not using PocketBase as framework.**_
- Removed `forms.New*WithConfig()` factories to minimize ambiguities.
If you need to pass a transaction Dao you can use the new `SetDao(dao)` method available to the form instances.
- `forms.RecordUpsert.LoadData(data map[string]any)` now can bulk load external data from a map.
To load data from a request instance, you could use `forms.RecordUpsert.LoadRequest(r, optKeysPrefix = "")`.
- `schema.RelationOptions.MaxSelect` has new type `*int` (_you can use the new `types.Pointer(123)` helper to assign pointer values_).
- Renamed the constant `apis.ContextUserKey` (_"user"_) to `apis.ContextAuthRecordKey` (_"authRecord"_).
- Replaced user related middlewares with their auth record alternative:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>apis.Require<strong>User</strong>Auth()</td>
<td>apis.Require<strong>Record</strong>Auth(<strong>optCollectionNames ...string</strong>)</td>
</tr>
<tr valign="top">
<td>apis.RequireAdminOr<strong>User</strong>Auth()</td>
<td>apis.RequireAdminOr<strong>Record</strong>Auth(<strong>optCollectionNames ...string</strong>)</td>
</tr>
<tr valign="top">
<td>N/A</td>
<td>
<strong>RequireSameContextRecordAuth()</strong>
<br/>
<em>(requires the auth record to be from the same context collection)</em>
</td>
</tr>
</table>
- The following record Dao helpers now uses the collection id or name instead of `*models.Collection` instance to reduce the verbosity when fetching records:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>dao.FindRecordById(<strong>collection</strong>, ...)</td>
<td>dao.FindRecordById(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.FindRecordsByIds(<strong>collection</strong>, ...)</td>
<td>dao.FindRecordsByIds(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.FindRecordsByExpr(<strong>collection</strong>, ...)</td>
<td>dao.FindRecordsByExpr(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.FindFirstRecordByData(<strong>collection</strong>, ...)</td>
<td>dao.FindFirstRecordByData(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.IsRecordValueUnique(<strong>collection</strong>, ...)</td>
<td>dao.IsRecordValueUnique(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
</table>
- Replaced all User related Dao helpers with Record equivalents:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>dao.UserQuery()</td>
<td>dao.RecordQuery(collection)</td>
</tr>
<tr valign="top">
<td>dao.FindUserById(id)</td>
<td>dao.FindRecordById(collectionNameOrId, id)</td>
</tr>
<tr valign="top">
<td>dao.FindUserByToken(token, baseKey)</td>
<td>dao.FindAuthRecordByToken(token, baseKey)</td>
</tr>
<tr valign="top">
<td>dao.FindUserByEmail(email)</td>
<td>dao.FindAuthRecordByEmail(collectionNameOrId, email)</td>
</tr>
<tr valign="top">
<td>N/A</td>
<td>dao.FindAuthRecordByUsername(collectionNameOrId, username)</td>
</tr>
</table>
- Moved the formatted `ApiError` struct and factories to the `github.com/pocketbase/pocketbase/apis` subpackage:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td colspan="2"><em>Import path</em></td>
</tr>
<tr valign="top">
<td>github.com/pocketbase/pocketbase/<strong>tools/rest</strong></td>
<td>github.com/pocketbase/pocketbase/<strong>apis</strong></td>
</tr>
<tr valign="top">
<td colspan="2"><em>Fields</em></td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.ApiError{}</td>
<td><strong>apis</strong>.ApiError{}</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewNotFoundError()</td>
<td><strong>apis</strong>.NewNotFoundError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewBadRequestError()</td>
<td><strong>apis</strong>.NewBadRequestError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewForbiddenError()</td>
<td><strong>apis</strong>.NewForbiddenError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewUnauthorizedError()</td>
<td><strong>apis</strong>.NewUnauthorizedError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewApiError()</td>
<td><strong>apis</strong>.NewApiError()</td>
</tr>
</table>
- Renamed `models.Record` helper getters:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>Set<strong>DataValue</strong></td>
<td>Set</td>
</tr>
<tr valign="top">
<td>Get<strong>DataValue</strong></td>
<td>Get</td>
</tr>
<tr valign="top">
<td>GetBool<strong>DataValue</strong></td>
<td>GetBool</td>
</tr>
<tr valign="top">
<td>GetString<strong>DataValue</strong></td>
<td>GetString</td>
</tr>
<tr valign="top">
<td>GetInt<strong>DataValue</strong></td>
<td>GetInt</td>
</tr>
<tr valign="top">
<td>GetFloat<strong>DataValue</strong></td>
<td>GetFloat</td>
</tr>
<tr valign="top">
<td>GetTime<strong>DataValue</strong></td>
<td>GetTime</td>
</tr>
<tr valign="top">
<td>GetDateTime<strong>DataValue</strong></td>
<td>GetDateTime</td>
</tr>
<tr valign="top">
<td>GetStringSlice<strong>DataValue</strong></td>
<td>GetStringSlice</td>
</tr>
</table>
- Added new auth collection `models.Record` helpers:
```go
func (m *Record) Username() string
func (m *Record) SetUsername(username string) error
func (m *Record) Email() string
func (m *Record) SetEmail(email string) error
func (m *Record) EmailVisibility() bool
func (m *Record) SetEmailVisibility(visible bool) error
func (m *Record) IgnoreEmailVisibility(state bool)
func (m *Record) Verified() bool
func (m *Record) SetVerified(verified bool) error
func (m *Record) TokenKey() string
func (m *Record) SetTokenKey(key string) error
func (m *Record) RefreshTokenKey() error
func (m *Record) LastResetSentAt() types.DateTime
func (m *Record) SetLastResetSentAt(dateTime types.DateTime) error
func (m *Record) LastVerificationSentAt() types.DateTime
func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error
func (m *Record) ValidatePassword(password string) bool
func (m *Record) SetPassword(password string) error
```
- Added option to return serialized custom `models.Record` fields data:
```go
func (m *Record) UnknownData() map[string]any
func (m *Record) WithUnkownData(state bool)
```
- Deleted `model.User`. Now the user data is stored as an auth `models.Record`.
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>User.Email</td>
<td>Record.Email()</td>
</tr>
<tr valign="top">
<td>User.TokenKey</td>
<td>Record.TokenKey()</td>
</tr>
<tr valign="top">
<td>User.Verified</td>
<td>Record.Verified()</td>
</tr>
<tr valign="top">
<td>User.SetPassword()</td>
<td>Record.SetPassword()</td>
</tr>
<tr valign="top">
<td>User.RefreshTokenKey()</td>
<td>Record.RefreshTokenKey()</td>
</tr>
<tr valign="top">
<td colspan="2"><em>etc.</em></td>
</tr>
</table>
- Replaced `User` related event hooks with their `Record` alternative:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>OnMailerBefore<strong>User</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerBefore<strong>Record</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerAfter<strong>User</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerAfter<strong>Record</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerBefore<strong>User</strong>VerificationSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerBefore<strong>Record</strong>VerificationSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerAfter<strong>User</strong>VerificationSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerAfter<strong>Record</strong>VerificationSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerBefore<strong>User</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerBefore<strong>Record</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerAfter<strong>User</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerAfter<strong>Record</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>On<strong>Users</strong>ListRequest() *hook.Hook[*<strong>User</strong>ListEvent]</td>
<td>On<strong>Records</strong>ListRequest() *hook.Hook[*<strong>Records</strong>ListEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>ViewRequest() *hook.Hook[*<strong>User</strong>ViewEvent]</td>
<td>On<strong>Record</strong>ViewRequest() *hook.Hook[*<strong>Record</strong>ViewEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeCreateRequest() *hook.Hook[*<strong>User</strong>CreateEvent]</td>
<td>On<strong>Record</strong>BeforeCreateRequest() *hook.Hook[*<strong>Record</strong>CreateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterCreateRequest() *hook.Hook[*<strong>User</strong>CreateEvent]</td>
<td>On<strong>Record</strong>AfterCreateRequest() *hook.Hook[*<strong>Record</strong>CreateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeUpdateRequest() *hook.Hook[*<strong>User</strong>UpdateEvent]</td>
<td>On<strong>Record</strong>BeforeUpdateRequest() *hook.Hook[*<strong>Record</strong>UpdateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterUpdateRequest() *hook.Hook[*<strong>User</strong>UpdateEvent]</td>
<td>On<strong>Record</strong>AfterUpdateRequest() *hook.Hook[*<strong>Record</strong>UpdateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeDeleteRequest() *hook.Hook[*<strong>User</strong>DeleteEvent]</td>
<td>On<strong>Record</strong>BeforeDeleteRequest() *hook.Hook[*<strong>Record</strong>DeleteEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterDeleteRequest() *hook.Hook[*<strong>User</strong>DeleteEvent]</td>
<td>On<strong>Record</strong>AfterDeleteRequest() *hook.Hook[*<strong>Record</strong>DeleteEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AuthRequest() *hook.Hook[*<strong>User</strong>AuthEvent]</td>
<td>On<strong>Record</strong>AuthRequest() *hook.Hook[*<strong>Record</strong>AuthEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>ListExternalAuths() *hook.Hook[*<strong>User</strong>ListExternalAuthsEvent]</td>
<td>On<strong>Record</strong>ListExternalAuths() *hook.Hook[*<strong>Record</strong>ListExternalAuthsEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeUnlinkExternalAuthRequest() *hook.Hook[*<strong>User</strong>UnlinkExternalAuthEvent]</td>
<td>On<strong>Record</strong>BeforeUnlinkExternalAuthRequest() *hook.Hook[*<strong>Record</strong>UnlinkExternalAuthEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterUnlinkExternalAuthRequest() *hook.Hook[*<strong>User</strong>UnlinkExternalAuthEvent]</td>
<td>On<strong>Record</strong>AfterUnlinkExternalAuthRequest() *hook.Hook[*<strong>Record</strong>UnlinkExternalAuthEvent]</td>
</tr>
</table>
- Replaced `forms.UserEmailLogin{}` with `forms.RecordPasswordLogin{}` (for both username and email depending on which is enabled for the collection).
- Renamed user related `core.Settings` fields:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>AuthToken{}</td>
<td>core.Settings.<strong>Record</strong>AuthToken{}</td>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>PasswordResetToken{}</td>
<td>core.Settings.<strong>Record</strong>PasswordResetToken{}</td>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>EmailChangeToken{}</td>
<td>core.Settings.<strong>Record</strong>EmailChangeToken{}</td>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>VerificationToken{}</td>
<td>core.Settings.<strong>Record</strong>VerificationToken{}</td>
</tr>
</table>
- Marked as "Deprecated" and will be removed in v0.9+:
```
core.Settings.EmailAuth{}
core.EmailAuthConfig{}
schema.FieldTypeUser
schema.UserOptions{}
```
- The second argument of `apis.StaticDirectoryHandler(fileSystem, enableIndexFallback)` now is used to enable/disable index.html forwarding on missing file (eg. in case of SPA).
+80
View File
@@ -0,0 +1,80 @@
Contributing to PocketBase
======================================================================
Thanks for taking the time to improve PocketBase!
This document describes how to prepare a PR for a change in the main repository.
- [Prerequisites](#prerequisites)
- [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)
- 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
```
> 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.
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
1. Navigate to `examples/base`
2. Run `go run main.go serve`
This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it!
**Before making a PR to the main repository, it is a good idea to:**
- 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
```
- 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
```
## Making changes in the Admin UI
PocketBase Admin UI is a single-page application (SPA) built with Svelte and Vite.
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
```
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).
> 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.
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
+2 -2
View File
@@ -2,8 +2,8 @@ lint:
golangci-lint run -c ./golangci.yml ./...
test:
go test -v --cover ./...
go test ./... -v --cover
test-report:
go test -v --cover -coverprofile=coverage.out ./...
go test ./... -v --cover -coverprofile=coverage.out
go tool cover -html=coverage.out
+24 -12
View File
@@ -1,6 +1,6 @@
<p align="center">
<a href="https://pocketbase.io" target="_blank" rel="noopener">
<img src="https://i.imgur.com/ZfD4BHO.png" alt="PocketBase - open source backend in 1 file" />
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" />
</a>
</p>
@@ -19,8 +19,7 @@
**For documentation and examples, please visit https://pocketbase.io/docs.**
> ⚠️ Although the web API defintions are considered stable,
> please keep in mind that PocketBase is still under active development
> ⚠️ Please keep in mind that PocketBase is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
@@ -29,12 +28,12 @@
The easiest way to interact with the API is to use one of the official SDK clients:
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
- **Dart** - _soon_
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview
PocketBase could be used as a standalone app or as a Go framework/toolkit that enables you to build
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
your own custom app specific business logic and still have a single portable executable at the end.
### Installation
@@ -64,15 +63,15 @@ func main() {
app := pocketbase.New()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// add new "GET /api/hello" route to the app router (echo)
// add new "GET /hello" route to the app router (echo)
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/hello",
Path: "/hello",
Handler: func(c echo.Context) error {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.ActivityLogger(app),
},
})
@@ -91,10 +90,12 @@ Running/building the application is the same as for any other Go program, aka. j
**PocketBase embeds SQLite, but doesn't require CGO.**
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively.
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
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.
### Testing
PocketBase comes with mixed bag of unit and integration tests.
@@ -115,11 +116,22 @@ 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).
You are free to do whatever you want with it, even offering it as a paid service.
You could help continuing its development by:
- [Suggest new features, report issues and fix bugs](https://github.com/pocketbase/pocketbase/issues)
- [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us)
> 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.
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. 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.
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._
+51 -44
View File
@@ -9,20 +9,19 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
)
// BindAdminApi registers the admin api endpoints and the corresponding handlers.
func BindAdminApi(app core.App, rg *echo.Group) {
// bindAdminApi registers the admin api endpoints and the corresponding handlers.
func bindAdminApi(app core.App, rg *echo.Group) {
api := adminApi{app: app}
subGroup := rg.Group("/admins", ActivityLogger(app))
subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
subGroup.GET("/:id", api.view, RequireAdminAuth())
@@ -37,7 +36,7 @@ type adminApi struct {
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
if tokenErr != nil {
return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.AdminAuthEvent{
@@ -54,24 +53,24 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
})
}
func (api *adminApi) refresh(c echo.Context) error {
func (api *adminApi) authRefresh(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil {
return rest.NewNotFoundError("Missing auth admin context.", nil)
return NewNotFoundError("Missing auth admin context.", nil)
}
return api.authResponse(c, admin)
}
func (api *adminApi) emailAuth(c echo.Context) error {
func (api *adminApi) authWithPassword(c echo.Context) error {
form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, admin)
@@ -80,11 +79,11 @@ func (api *adminApi) emailAuth(c echo.Context) error {
func (api *adminApi) requestPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occurred while validating the form.", err)
return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show the result
@@ -101,15 +100,15 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
_, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to set new password.", submitErr)
return NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, admin)
return c.NoContent(http.StatusNoContent)
}
func (api *adminApi) list(c echo.Context) error {
@@ -121,10 +120,10 @@ func (api *adminApi) list(c echo.Context) error {
result, err := search.NewProvider(fieldResolver).
Query(api.app.Dao().AdminQuery()).
ParseAndExec(c.QueryString(), &admins)
ParseAndExec(c.QueryParams().Encode(), &admins)
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
event := &core.AdminsListEvent{
@@ -141,12 +140,12 @@ func (api *adminApi) list(c echo.Context) error {
func (api *adminApi) view(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.AdminViewEvent{
@@ -166,7 +165,7 @@ func (api *adminApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminCreateEvent{
@@ -174,38 +173,42 @@ func (api *adminApi) create(c echo.Context) error {
Admin: admin,
}
handlerErr := api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
// create the admin
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to create admin.", err)
}
// create the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil {
return NewBadRequestError("Failed to create admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnAdminAfterCreateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *adminApi) update(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
form := forms.NewAdminUpsert(api.app, admin)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminUpdateEvent{
@@ -213,31 +216,35 @@ func (api *adminApi) update(c echo.Context) error {
Admin: admin,
}
handlerErr := api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
// update the admin
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to update admin.", err)
}
// update the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil {
return NewBadRequestError("Failed to update admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnAdminAfterUpdateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *adminApi) delete(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.AdminDeleteEvent{
@@ -247,7 +254,7 @@ func (api *adminApi) delete(c echo.Context) error {
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
return rest.NewBadRequestError("Failed to delete admin.", err)
return NewBadRequestError("Failed to delete admin.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
+208 -142
View File
@@ -4,46 +4,57 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAdminAuth(t *testing.T) {
func TestAdminAuthWithEmail(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
ExpectedContent: []string{`"data":{"identity":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
},
{
Name: "invalid data",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "wrong email/password",
Name: "wrong email",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`),
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "wrong password",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid email/password (already authorized)",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
@@ -51,11 +62,11 @@ func TestAdminAuth(t *testing.T) {
{
Name: "valid email/password (guest)",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -92,6 +103,7 @@ func TestAdminRequestPasswordReset(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins/request-password-reset",
Body: strings.NewReader(`{"email":"missing@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
},
{
@@ -99,22 +111,34 @@ func TestAdminRequestPasswordReset(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
// usually this events are fired but since the submit is
// executed in a separate go routine they are fired async
// ExpectedEvents: map[string]int{
// "OnModelBeforeUpdate": 1,
// "OnModelAfterUpdate": 1,
// "OnMailerBeforeUserResetPasswordSend:1": 1,
// "OnMailerAfterUserResetPasswordSend:1": 1,
// },
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeAdminResetPasswordSend": 1,
"OnMailerAfterAdminResetPasswordSend": 1,
},
},
{
Name: "existing admin (after already sent)",
Method: http.MethodPost,
Url: "/api/admins/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// simulate recent password request
admin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
admin.LastResetSentAt = types.NowDateTime()
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
if err := dao.Save(admin); err != nil {
t.Fatal(err)
}
},
},
}
@@ -142,27 +166,42 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "expired token",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA","password":"1234567890","passwordConfirm":"1234567890"}`),
Name: "expired token",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
"password":"1234567890",
"passwordConfirm":"1234567890"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`},
},
{
Name: "valid token",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw","password":"1234567890","passwordConfirm":"1234567890"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"token":`,
},
Name: "valid token + invalid password",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"password":"123456",
"passwordConfirm":"123456"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"password":{"code":"validation_length_out_of_range"`},
},
{
Name: "valid token + valid password",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"password":"1234567891",
"passwordConfirm":"1234567891"
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminAuthRequest": 1,
},
},
}
@@ -177,30 +216,40 @@ func TestAdminRefresh(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/admins/refresh",
Url: "/api/admins/auth-refresh",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPost,
Url: "/api/admins/refresh",
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin",
Name: "authorized as admin (expired token)",
Method: http.MethodPost,
Url: "/api/admins/refresh",
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (valid token)",
Method: http.MethodPost,
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -228,7 +277,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -238,16 +287,17 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":2`,
`"totalItems":3`,
`"items":[{`,
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sywbhecnh46rhm0"`,
`"id":"sbmbsdb40jyxf7h"`,
`"id":"9q2trqumvlyr3bd"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -258,15 +308,19 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?page=2&perPage=1&sort=-created",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":1`,
`"totalItems":2`,
`"totalItems":3`,
`"items":[{`,
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"id":"sbmbsdb40jyxf7h"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -277,7 +331,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?filter=invalidfield~'test2'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -285,9 +339,9 @@ func TestAdminsList(t *testing.T) {
{
Name: "authorized as admin + valid filter",
Method: http.MethodGet,
Url: "/api/admins?filter=email~'test2'",
Url: "/api/admins?filter=email~'test3'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -295,7 +349,11 @@ func TestAdminsList(t *testing.T) {
`"perPage":30`,
`"totalItems":1`,
`"items":[{`,
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"9q2trqumvlyr3bd"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -313,36 +371,26 @@ func TestAdminView(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + invalid admin id",
Method: http.MethodGet,
Url: "/api/admins/invalid",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodGet,
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
Url: "/api/admins/nonexisting",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -350,13 +398,17 @@ func TestAdminView(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sbmbsdb40jyxf7h"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminViewRequest": 1,
@@ -374,36 +426,26 @@ func TestAdminDelete(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + invalid admin id",
Name: "authorized as admin + missing admin id",
Method: http.MethodDelete,
Url: "/api/admins/invalid",
Url: "/api/admins/missing",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodDelete,
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -411,9 +453,9 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -426,15 +468,15 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin - try to delete the only remaining admin",
Method: http.MethodDelete,
Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
Url: "/api/admins/sywbhecnh46rhm0",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins except the authorized one
adminModel := &models.Admin{}
_, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{
"id": "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
"id": "sywbhecnh46rhm0",
})).Execute()
if err != nil {
t.Fatal(err)
@@ -467,7 +509,7 @@ func TestAdminCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
if err != nil {
@@ -492,7 +534,7 @@ func TestAdminCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -503,13 +545,10 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeCreateRequest": 1,
},
},
{
Name: "authorized as admin + invalid data format",
@@ -517,7 +556,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -526,23 +565,36 @@ func TestAdminCreate(t *testing.T) {
Name: "authorized as admin + invalid data",
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
Body: strings.NewReader(`{
"email":"test@example.com",
"password":"1234",
"passwordConfirm":"4321",
"avatar":99
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeCreateRequest": 1,
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"avatar":{"code":"validation_max_less_equal_than_required"`,
`"email":{"code":"validation_admin_email_exists"`,
`"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
},
},
{
Name: "authorized as admin + valid data",
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567890",
"passwordConfirm":"1234567890",
"avatar":3
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -550,6 +602,12 @@ func TestAdminCreate(t *testing.T) {
`"email":"testnew@example.com"`,
`"avatar":3`,
},
NotExpectedContent: []string{
`"password"`,
`"passwordConfirm"`,
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
@@ -569,38 +627,27 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + invalid admin id",
Name: "authorized as admin + missing admin",
Method: http.MethodPatch,
Url: "/api/admins/invalid",
Url: "/api/admins/missing",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodPatch,
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -608,14 +655,14 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + empty data",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sbmbsdb40jyxf7h"`,
`"email":"test2@example.com"`,
`"avatar":2`,
},
@@ -629,10 +676,10 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid formatted data",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -640,30 +687,49 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid data",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
"email":"test@example.com",
"password":"1234",
"passwordConfirm":"4321",
"avatar":99
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeUpdateRequest": 1,
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"avatar":{"code":"validation_max_less_equal_than_required"`,
`"email":{"code":"validation_admin_email_exists"`,
`"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
},
},
{
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`),
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567891",
"passwordConfirm":"1234567891",
"avatar":5
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sbmbsdb40jyxf7h"`,
`"email":"testnew@example.com"`,
`"avatar":5`,
},
NotExpectedContent: []string{
`"password"`,
`"passwordConfirm"`,
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
@@ -1,4 +1,4 @@
package rest
package apis
import (
"net/http"
@@ -8,7 +8,7 @@ import (
"github.com/pocketbase/pocketbase/tools/inflector"
)
// ApiError defines the properties for a basic api error response.
// ApiError defines the struct for a basic api error response.
type ApiError struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -23,6 +23,7 @@ func (e *ApiError) Error() string {
return e.Message
}
// RawData returns the unformatted error data (could be an internal error, text, etc.)
func (e *ApiError) RawData() any {
return e.rawData
}
@@ -1,4 +1,4 @@
package rest_test
package apis_test
import (
"encoding/json"
@@ -6,11 +6,11 @@ import (
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/apis"
)
func TestNewApiErrorWithRawData(t *testing.T) {
e := rest.NewApiError(
e := apis.NewApiError(
300,
"message_test",
"rawData_test",
@@ -33,7 +33,7 @@ func TestNewApiErrorWithRawData(t *testing.T) {
}
func TestNewApiErrorWithValidationData(t *testing.T) {
e := rest.NewApiError(
e := apis.NewApiError(
300,
"message_test",
validation.Errors{
@@ -77,7 +77,7 @@ func TestNewNotFoundError(t *testing.T) {
}
for i, scenario := range scenarios {
e := rest.NewNotFoundError(scenario.message, scenario.data)
e := apis.NewNotFoundError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -98,7 +98,7 @@ func TestNewBadRequestError(t *testing.T) {
}
for i, scenario := range scenarios {
e := rest.NewBadRequestError(scenario.message, scenario.data)
e := apis.NewBadRequestError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -119,7 +119,7 @@ func TestNewForbiddenError(t *testing.T) {
}
for i, scenario := range scenarios {
e := rest.NewForbiddenError(scenario.message, scenario.data)
e := apis.NewForbiddenError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -140,7 +140,7 @@ func TestNewUnauthorizedError(t *testing.T) {
}
for i, scenario := range scenarios {
e := rest.NewUnauthorizedError(scenario.message, scenario.data)
e := apis.NewUnauthorizedError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
+72 -43
View File
@@ -2,6 +2,7 @@
package apis
import (
"errors"
"fmt"
"io/fs"
"log"
@@ -13,11 +14,12 @@ 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"
)
const trailedAdminPath = "/_/"
// InitApi creates a configured echo instance with registered
// system and app specific routes and middlewares.
func InitApi(app core.App) (*echo.Echo, error) {
@@ -25,7 +27,12 @@ func InitApi(app core.App) (*echo.Echo, error) {
e.Debug = app.IsDebug()
// default middlewares
e.Pre(middleware.RemoveTrailingSlash())
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
Skipper: func(c echo.Context) bool {
// ignore Admin UI route(s)
return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath)
},
}))
e.Use(middleware.Recover())
e.Use(middleware.Secure())
e.Use(LoadAuthContext(app))
@@ -36,7 +43,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
return
}
var apiErr *rest.ApiError
var apiErr *ApiError
switch v := err.(type) {
case *echo.HTTPError:
@@ -44,8 +51,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message)
apiErr = rest.NewApiError(v.Code, msg, v)
case *rest.ApiError:
apiErr = NewApiError(v.Code, msg, v)
case *ApiError:
if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData())
}
@@ -54,22 +61,30 @@ func InitApi(app core.App) (*echo.Echo, error) {
if err != nil && app.IsDebug() {
log.Println(err)
}
apiErr = rest.NewBadRequestError("", err)
apiErr = NewBadRequestError("", err)
}
// Send response
var cErr error
if c.Request().Method == http.MethodHead {
// @see https://github.com/labstack/echo/issues/608
cErr = c.NoContent(apiErr.Code)
} else {
cErr = c.JSON(apiErr.Code, apiErr)
event := &core.ApiErrorEvent{
HttpContext: c,
Error: apiErr,
}
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
// @see https://github.com/labstack/echo/issues/608
if e.HttpContext.Request().Method == http.MethodHead {
return e.HttpContext.NoContent(apiErr.Code)
}
return e.HttpContext.JSON(apiErr.Code, apiErr)
})
// truly rare case; eg. client already disconnected
if cErr != nil && app.IsDebug() {
log.Println(err)
if hookErr != nil && app.IsDebug() {
log.Println(hookErr)
}
app.OnAfterApiError().Trigger(event)
}
// admin ui routes
@@ -77,14 +92,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
// default routes
api := e.Group("/api")
BindSettingsApi(app, api)
BindAdminApi(app, api)
BindUserApi(app, api)
BindCollectionApi(app, api)
BindRecordApi(app, api)
BindFileApi(app, api)
BindRealtimeApi(app, api)
BindLogsApi(app, api)
bindSettingsApi(app, api)
bindAdminApi(app, api)
bindCollectionApi(app, api)
bindRecordCrudApi(app, api)
bindRecordAuthApi(app, api)
bindFileApi(app, api)
bindRealtimeApi(app, api)
bindLogsApi(app, api)
// trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes
@@ -107,42 +122,50 @@ func InitApi(app core.App) (*echo.Echo, error) {
// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
//
// If a file resource is missing and indexFallback is set, the request
// will be forwarded to the base index.html (useful also for SPA).
//
// @see https://github.com/labstack/echo/issues/2211
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.HandlerFunc {
func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
return func(c echo.Context) error {
p := c.PathParam("*")
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
tmpPath, err := url.PathUnescape(p)
if err != nil {
return fmt.Errorf("failed to unescape path variable: %w", err)
}
p = tmpPath
// escape url path
tmpPath, err := url.PathUnescape(p)
if err != nil {
return fmt.Errorf("failed to unescape path variable: %w", err)
}
p = tmpPath
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
return c.FileFS(name, fileSystem)
fileErr := c.FileFS(name, fileSystem)
if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
return c.FileFS("index.html", fileSystem)
}
return fileErr
}
}
// bindStaticAdminUI registers the endpoints that serves the static admin UI.
func bindStaticAdminUI(app core.App, e *echo.Echo) error {
// serves /ui/dist/index.html file
// (explicit route is used to avoid conflicts with `RemoveTrailingSlash` middleware)
e.FileFS(
"/_",
"index.html",
ui.DistIndexHTML,
middleware.Gzip(),
installerRedirect(app),
// redirect to trailing slash to ensure that relative urls will still work properly
e.GET(
strings.TrimRight(trailedAdminPath, "/"),
func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath)
},
)
// serves static files from the /ui/dist directory
// (similar to echo.StaticFS but with gzip middleware enabled)
e.GET(
"/_/*",
StaticDirectoryHandler(ui.DistDirFS, false),
trailedAdminPath+"*",
echo.StaticDirectoryHandler(ui.DistDirFS, false),
installerRedirect(app),
middleware.Gzip(),
)
@@ -175,6 +198,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// skip redirect checks for non-root level index.html requests
path := c.Request().URL.Path
if path != trailedAdminPath && path != trailedAdminPath+"index.html" {
return next(c)
}
// load into cache (if not already)
if !app.Cache().Has(totalAdminsCacheKey) {
if err := updateTotalAdminsCache(app); err != nil {
@@ -188,12 +217,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
if totalAdmins == 0 && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, "/_/?installer#")
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page
return c.Redirect(http.StatusTemporaryRedirect, "/_/#/")
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/")
}
return next(c)
+6 -6
View File
@@ -6,8 +6,8 @@ import (
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/rest"
)
func Test404(t *testing.T) {
@@ -54,7 +54,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Name: "custom route",
Method: http.MethodGet,
Url: "/custom",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/custom",
@@ -70,7 +70,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Name: "route with HTTPError",
Method: http.MethodGet,
Url: "/http-error",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/http-error",
@@ -86,12 +86,12 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Name: "route with api error",
Method: http.MethodGet,
Url: "/api-error",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api-error",
Handler: func(c echo.Context) error {
return rest.NewApiError(500, "test message", errors.New("internal_test"))
return apis.NewApiError(500, "test message", errors.New("internal_test"))
},
})
},
@@ -102,7 +102,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Name: "route with plain error",
Method: http.MethodGet,
Url: "/plain-error",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/plain-error",
+68 -49
View File
@@ -1,20 +1,17 @@
package apis
import (
"errors"
"log"
"net/http"
"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/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
// BindCollectionApi registers the collection api endpoints and the corresponding handlers.
func BindCollectionApi(app core.App, rg *echo.Group) {
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
func bindCollectionApi(app core.App, rg *echo.Group) {
api := collectionApi{app: app}
subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth())
@@ -23,6 +20,7 @@ func BindCollectionApi(app core.App, rg *echo.Group) {
subGroup.GET("/:collection", api.view)
subGroup.PATCH("/:collection", api.update)
subGroup.DELETE("/:collection", api.delete)
subGroup.PUT("/import", api.bulkImport)
}
type collectionApi struct {
@@ -31,17 +29,17 @@ type collectionApi struct {
func (api *collectionApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(
"id", "created", "updated", "name", "system",
"id", "created", "updated", "name", "system", "type",
)
collections := []*models.Collection{}
result, err := search.NewProvider(fieldResolver).
Query(api.app.Dao().CollectionQuery()).
ParseAndExec(c.QueryString(), &collections)
ParseAndExec(c.QueryParams().Encode(), &collections)
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
event := &core.CollectionsListEvent{
@@ -58,7 +56,7 @@ func (api *collectionApi) list(c echo.Context) error {
func (api *collectionApi) view(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.CollectionViewEvent{
@@ -76,9 +74,9 @@ func (api *collectionApi) create(c echo.Context) error {
form := forms.NewCollectionUpsert(api.app, collection)
// read
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionCreateEvent{
@@ -86,33 +84,37 @@ func (api *collectionApi) create(c echo.Context) error {
Collection: collection,
}
handlerErr := api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
// submit
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to create the collection.", err)
}
// create the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if err := next(); err != nil {
return NewBadRequestError("Failed to create the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnCollectionAfterCreateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *collectionApi) update(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
form := forms.NewCollectionUpsert(api.app, collection)
// read
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionUpdateEvent{
@@ -120,26 +122,30 @@ func (api *collectionApi) update(c echo.Context) error {
Collection: collection,
}
handlerErr := api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
// submit
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to update the collection.", err)
}
// update the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if err := next(); err != nil {
return NewBadRequestError("Failed to update the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
return e.HttpContext.JSON(http.StatusOK, e.Collection)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnCollectionAfterUpdateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *collectionApi) delete(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.CollectionDeleteEvent{
@@ -149,14 +155,7 @@ func (api *collectionApi) delete(c echo.Context) error {
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
return rest.NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
}
// try to delete the collection files
if err := api.deleteCollectionFiles(e.Collection); err != nil && api.app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
@@ -169,17 +168,37 @@ func (api *collectionApi) delete(c echo.Context) error {
return handlerErr
}
func (api *collectionApi) deleteCollectionFiles(collection *models.Collection) error {
fs, err := api.app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
func (api *collectionApi) bulkImport(c echo.Context) error {
form := forms.NewCollectionsImport(api.app)
failed := fs.DeletePrefix(collection.BaseFilesPath())
if len(failed) > 0 {
return errors.New("Failed to delete all record files.")
// load request data
if err := c.Bind(form); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
return nil
event := &core.CollectionsImportEvent{
HttpContext: c,
Collections: form.Collections,
}
// import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
if err := next(); err != nil {
return NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnCollectionsAfterImportRequest().Trigger(event)
}
return submitErr
}
+654 -83
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -6,14 +6,13 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
)
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg"}
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
var defaultThumbSizes = []string{"100x100"}
// BindFileApi registers the file api endpoints and the corresponding handlers.
func BindFileApi(app core.App, rg *echo.Group) {
// bindFileApi registers the file api endpoints and the corresponding handlers.
func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app}
subGroup := rg.Group("/files", ActivityLogger(app))
@@ -27,30 +26,30 @@ type fileApi struct {
func (api *fileApi) download(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
recordId := c.PathParam("recordId")
if recordId == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
record, err := api.app.Dao().FindRecordById(collection, recordId, nil)
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
filename := c.PathParam("filename")
fileField := record.FindFileFieldByFile(filename)
if fileField == nil {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
fs, err := api.app.NewFilesystem()
if err != nil {
return rest.NewBadRequestError("Filesystem initialization failure.", err)
return NewBadRequestError("Filesystem initialization failure.", err)
}
defer fs.Close()
@@ -64,7 +63,7 @@ func (api *fileApi) download(c echo.Context) error {
// extract the original file meta attributes and check it existence
oAttrs, oAttrsErr := fs.Attributes(originalPath)
if oAttrsErr != nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
// check if it is an image
@@ -78,7 +77,7 @@ func (api *fileApi) download(c echo.Context) error {
// - if exists - compare last modified dates to determine whether the thumb should be recreated
tAttrs, tAttrsErr := fs.Attributes(servedPath)
if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) {
if err := fs.CreateThumb(originalPath, servedPath, thumbSize, false); err != nil {
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
servedPath = originalPath // fallback to the original
}
}
@@ -95,8 +94,10 @@ func (api *fileApi) download(c echo.Context) error {
}
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil {
return rest.NewNotFoundError("", err)
res := e.HttpContext.Response()
req := e.HttpContext.Request()
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
return NewNotFoundError("", err)
}
return nil
+94 -13
View File
@@ -14,9 +14,15 @@ import (
func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt")
testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png")
testThumbPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/100x100_4881bdef-06b4-4dea-8d97-6125ad242677.png")
testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt")
testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png")
testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png")
testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png")
testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png")
testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png")
testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png")
testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png")
testFile, fileErr := os.ReadFile(testFilePath)
if fileErr != nil {
@@ -28,7 +34,32 @@ func TestFileDownload(t *testing.T) {
t.Fatal(imgErr)
}
testThumb, thumbErr := os.ReadFile(testThumbPath)
testThumbCropCenter, thumbErr := os.ReadFile(testThumbCropCenterPath)
if thumbErr != nil {
t.Fatal(thumbErr)
}
testThumbCropTop, thumbErr := os.ReadFile(testThumbCropTopPath)
if thumbErr != nil {
t.Fatal(thumbErr)
}
testThumbCropBottom, thumbErr := os.ReadFile(testThumbCropBottomPath)
if thumbErr != nil {
t.Fatal(thumbErr)
}
testThumbFit, thumbErr := os.ReadFile(testThumbFitPath)
if thumbErr != nil {
t.Fatal(thumbErr)
}
testThumbZeroWidth, thumbErr := os.ReadFile(testThumbZeroWidthPath)
if thumbErr != nil {
t.Fatal(thumbErr)
}
testThumbZeroHeight, thumbErr := os.ReadFile(testThumbZeroHeightPath)
if thumbErr != nil {
t.Fatal(thumbErr)
}
@@ -37,28 +68,28 @@ func TestFileDownload(t *testing.T) {
{
Name: "missing collection",
Method: http.MethodGet,
Url: "/api/files/missing/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record",
Method: http.MethodGet,
Url: "/api/files/demo/00000000-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing file",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/00000000-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "existing image",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -68,7 +99,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - missing thumb (should fallback to the original)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=999x999",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -76,11 +107,61 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - existing thumb",
Name: "existing image - existing thumb (crop center)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=100x100",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumb)},
ExpectedContent: []string{string(testThumbCropCenter)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "existing image - existing thumb (crop top)",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropTop)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "existing image - existing thumb (crop bottom)",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropBottom)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "existing image - existing thumb (fit)",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbFit)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "existing image - existing thumb (zero width)",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroWidth)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
},
{
Name: "existing image - existing thumb (zero height)",
Method: http.MethodGet,
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroHeight)},
ExpectedEvents: map[string]int{
"OnFileDownloadRequest": 1,
},
@@ -88,7 +169,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing non image file - thumb parameter should be ignored",
Method: http.MethodGet,
Url: "/api/files/demo/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt?thumb=100x100",
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
+9 -10
View File
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
// BindLogsApi registers the request logs api endpoints.
func BindLogsApi(app core.App, rg *echo.Group) {
// bindLogsApi registers the request logs api endpoints.
func bindLogsApi(app core.App, rg *echo.Group) {
api := logsApi{app: app}
subGroup := rg.Group("/logs", RequireAdminAuth())
@@ -28,7 +27,7 @@ type logsApi struct {
var requestFilterFields = []string{
"rowid", "id", "created", "updated",
"url", "method", "status", "auth",
"ip", "referer", "userAgent",
"remoteIp", "userIp", "referer", "userAgent",
}
func (api *logsApi) requestsList(c echo.Context) error {
@@ -36,10 +35,10 @@ func (api *logsApi) requestsList(c echo.Context) error {
result, err := search.NewProvider(fieldResolver).
Query(api.app.LogsDao().RequestQuery()).
ParseAndExec(c.QueryString(), &[]*models.Request{})
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
return c.JSON(http.StatusOK, result)
@@ -55,13 +54,13 @@ func (api *logsApi) requestsStats(c echo.Context) error {
var err error
expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
if err != nil {
return rest.NewBadRequestError("Invalid filter format.", err)
return NewBadRequestError("Invalid filter format.", err)
}
}
stats, err := api.app.LogsDao().RequestsStats(expr)
if err != nil {
return rest.NewBadRequestError("Failed to generate requests stats.", err)
return NewBadRequestError("Failed to generate requests stats.", err)
}
return c.JSON(http.StatusOK, stats)
@@ -70,12 +69,12 @@ func (api *logsApi) requestsStats(c echo.Context) error {
func (api *logsApi) requestView(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
request, err := api.app.LogsDao().FindRequestById(id)
if err != nil || request == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
return c.JSON(http.StatusOK, request)
+20 -20
View File
@@ -18,11 +18,11 @@ func TestRequestsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -32,9 +32,9 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
t.Fatal(err)
}
@@ -54,9 +54,9 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests?filter=status>200",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
t.Fatal(err)
}
@@ -87,11 +87,11 @@ func TestRequestView(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -101,9 +101,9 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
t.Fatal(err)
}
@@ -116,9 +116,9 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
t.Fatal(err)
}
@@ -145,11 +145,11 @@ func TestRequestsStats(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -159,16 +159,16 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
`[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
{
@@ -176,16 +176,16 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats?filter=status>200",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`[{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
`[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
}
+163 -66
View File
@@ -3,6 +3,7 @@ package apis
import (
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
@@ -10,30 +11,32 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// Common request context keys used by the middlewares and api handlers.
const (
ContextUserKey string = "user"
ContextAdminKey string = "admin"
ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection"
)
// RequireGuestOnly middleware requires a request to NOT have a valid
// Authorization header set.
// Authorization header.
//
// This middleware is the opposite of [apis.RequireAdminOrUserAuth()].
// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
func RequireGuestOnly() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := rest.NewBadRequestError("The request can be accessed only by guests.", nil)
err := NewBadRequestError("The request can be accessed only by guests.", nil)
user, _ := c.Get(ContextUserKey).(*models.User)
if user != nil {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record != nil {
return err
}
@@ -47,14 +50,55 @@ func RequireGuestOnly() echo.MiddlewareFunc {
}
}
// RequireUserAuth middleware requires a request to have
// a valid user Authorization header set (aka. `Authorization: User ...`).
func RequireUserAuth() echo.MiddlewareFunc {
// RequireRecordAuth middleware requires a request to have
// a valid record auth Authorization header.
//
// The auth record could be from any collection.
//
// You can further filter the allowed record auth collections by
// specifying their names.
//
// Example:
// apis.RequireRecordAuth()
// Or:
// apis.RequireRecordAuth("users", "supervisors")
//
// To restrict the auth record only to the loaded context collection,
// use [apis.RequireSameContextRecordAuth()] instead.
func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user, _ := c.Get(ContextUserKey).(*models.User)
if user == nil {
return rest.NewUnauthorizedError("The request requires valid user authorization token to be set.", nil)
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
}
// check record collection name
if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
}
return next(c)
}
}
}
//
// RequireSameContextRecordAuth middleware requires a request to have
// a valid record Authorization header.
//
// The auth record must be from the same collection already loaded in the context.
func RequireSameContextRecordAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
}
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil || record.Collection().Id != collection.Id {
return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil)
}
return next(c)
@@ -63,13 +107,13 @@ func RequireUserAuth() echo.MiddlewareFunc {
}
// RequireAdminAuth middleware requires a request to have
// a valid admin Authorization header set (aka. `Authorization: Admin ...`).
// a valid admin Authorization header.
func RequireAdminAuth() 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 rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
return next(c)
@@ -77,15 +121,15 @@ func RequireAdminAuth() echo.MiddlewareFunc {
}
}
// RequireAdminAuthIfAny middleware requires a request to have
// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
// ONLY if the application has at least 1 existing Admin model.
// RequireAdminAuthOnlyIfAny middleware requires a request to have
// a valid admin Authorization header ONLY if the application has
// at least 1 existing Admin model.
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
totalAdmins, err := app.Dao().TotalAdmins()
if err != nil {
return rest.NewBadRequestError("Failed to fetch admins info.", err)
return NewBadRequestError("Failed to fetch admins info.", err)
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -94,24 +138,29 @@ func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return next(c)
}
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
}
}
// RequireAdminOrUserAuth middleware requires a request to have
// a valid admin or user Authorization header set
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
// RequireAdminOrRecordAuth middleware requires a request to have
// a valid admin or record Authorization header set.
//
// You can further filter the allowed auth record collections by providing their names.
//
// This middleware is the opposite of [apis.RequireGuestOnly()].
func RequireAdminOrUserAuth() echo.MiddlewareFunc {
func RequireAdminOrRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
user, _ := c.Get(ContextUserKey).(*models.User)
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if admin == nil && user == nil {
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
if admin == nil && record == nil {
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
}
if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
}
return next(c)
@@ -120,29 +169,33 @@ func RequireAdminOrUserAuth() echo.MiddlewareFunc {
}
// RequireAdminOrOwnerAuth middleware requires a request to have
// a valid admin or user owner Authorization header set
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
// a valid admin or auth record owner Authorization header set.
//
// This middleware is similar to [apis.RequireAdminOrUserAuth()] but
// for the user token expects to have the same id as the path parameter
// `ownerIdParam` (default to "id").
// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
// for the auth record token expects to have the same id as the path
// parameter ownerIdParam (default to "id" if empty).
func RequireAdminOrOwnerAuth(ownerIdParam string) 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)
}
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
}
if ownerIdParam == "" {
ownerIdParam = "id"
}
ownerId := c.PathParam(ownerIdParam)
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
if admin == nil && loggedUser == nil {
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
}
if admin == nil && loggedUser.Id != ownerId {
return rest.NewForbiddenError("You are not allowed to perform this request.", nil)
// note: it is "safe" to compare only the record id since the auth
// record ids are treated as unique across all auth collections
if record.Id != ownerId {
return NewForbiddenError("You are not allowed to perform this request.", nil)
}
return next(c)
@@ -151,32 +204,41 @@ func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
}
// LoadAuthContext middleware reads the Authorization request header
// and loads the token related user or admin instance into the
// and loads the token related record or admin instance into the
// request's context.
//
// This middleware is expected to be registered by default for all routes.
// This middleware is expected to be already registered by default for all routes.
func LoadAuthContext(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Request().Header.Get("Authorization")
if token == "" {
return next(c)
}
if token != "" {
if strings.HasPrefix(token, "User ") {
user, err := app.Dao().FindUserByToken(
token[5:],
app.Settings().UserAuthToken.Secret,
)
if err == nil && user != nil {
c.Set(ContextUserKey, user)
}
} else if strings.HasPrefix(token, "Admin ") {
admin, err := app.Dao().FindAdminByToken(
token[6:],
app.Settings().AdminAuthToken.Secret,
)
if err == nil && admin != nil {
c.Set(ContextAdminKey, admin)
}
// the schema is not required and it is only for
// compatibility with the defaults of some HTTP clients
token = strings.TrimPrefix(token, "Bearer ")
claims, _ := security.ParseUnverifiedJWT(token)
tokenType := cast.ToString(claims["type"])
switch tokenType {
case tokens.TypeAdmin:
admin, err := app.Dao().FindAdminByToken(
token,
app.Settings().AdminAuthToken.Secret,
)
if err == nil && admin != nil {
c.Set(ContextAdminKey, admin)
}
case tokens.TypeAuthRecord:
record, err := app.Dao().FindAuthRecordByToken(
token,
app.Settings().RecordAuthToken.Secret,
)
if err == nil && record != nil {
c.Set(ContextAuthRecordKey, record)
}
}
@@ -187,13 +249,19 @@ func LoadAuthContext(app core.App) echo.MiddlewareFunc {
// LoadCollectionContext middleware finds the collection with related
// path identifier and loads it into the request context.
func LoadCollectionContext(app core.App) echo.MiddlewareFunc {
//
// Set optCollectionTypes to further filter the found collection by its type.
func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if param := c.PathParam("collection"); param != "" {
collection, err := app.Dao().FindCollectionByNameOrId(param)
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
return NewBadRequestError("Invalid collection type.", nil)
}
c.Set(ContextCollectionKey, collection)
@@ -230,7 +298,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal)
case *rest.ApiError:
case *ApiError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData())
@@ -241,18 +309,21 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
}
requestAuth := models.RequestAuthGuest
if c.Get(ContextUserKey) != nil {
requestAuth = models.RequestAuthUser
if c.Get(ContextAuthRecordKey) != nil {
requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin
}
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
model := &models.Request{
Url: httpRequest.URL.RequestURI(),
Method: strings.ToLower(httpRequest.Method),
Status: status,
Auth: requestAuth,
Ip: httpRequest.RemoteAddr,
UserIp: realUserIp(httpRequest, ip),
RemoteIp: ip,
Referer: httpRequest.Referer(),
UserAgent: httpRequest.UserAgent(),
Meta: meta,
@@ -297,3 +368,29 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
}
}
}
// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
//
// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
func realUserIp(r *http.Request, fallbackIp string) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
ips := strings.Split(ipsList, ",")
// extract the rightmost ip
for i := len(ips) - 1; i >= 0; i-- {
ip := strings.TrimSpace(ips[i])
if ip != "" {
return ip
}
}
}
return fallbackIp
}
+444 -68
View File
@@ -12,13 +12,13 @@ import (
func TestRequireGuestOnly(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -38,9 +38,9 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -60,9 +60,9 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -81,7 +81,7 @@ func TestRequireGuestOnly(t *testing.T) {
Name: "guest",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -103,13 +103,13 @@ func TestRequireGuestOnly(t *testing.T) {
}
}
func TestRequireUserAuth(t *testing.T) {
func TestRequireRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -117,7 +117,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
@@ -129,9 +129,9 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -139,7 +139,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
@@ -151,9 +151,9 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -161,7 +161,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
@@ -169,13 +169,13 @@ func TestRequireUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -183,7 +183,167 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record token with collection not in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireRecordAuth("demo1", "demo2"),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token with collection in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireRecordAuth("demo1", "demo2", "users"),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireSameContextRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/users/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
Url: "/my/users/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid admin token",
Method: http.MethodGet,
Url: "/my/users/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token but from different collection",
Method: http.MethodGet,
Url: "/my/users/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireRecordAuth(),
},
})
},
@@ -203,7 +363,7 @@ func TestRequireAdminAuth(t *testing.T) {
Name: "guest",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -223,9 +383,9 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -241,13 +401,13 @@ func TestRequireAdminAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -267,9 +427,9 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -297,7 +457,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Name: "guest (while having at least 1 existing admin)",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -316,7 +476,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Name: "guest (while having 0 existing admins)",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
if err != nil {
@@ -342,9 +502,9 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -360,13 +520,13 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -386,9 +546,9 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -410,13 +570,13 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
}
}
func TestRequireAdminOrUserAuth(t *testing.T) {
func TestRequireAdminOrRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -424,7 +584,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -436,9 +596,9 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -446,7 +606,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -454,13 +614,13 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -468,7 +628,51 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record token with collection not in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrRecordAuth("demo1", "demo2", "clients"),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token with collection in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrRecordAuth("demo1", "demo2", "users"),
},
})
},
@@ -480,9 +684,9 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
@@ -490,7 +694,29 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid admin token + restricted collections list (should be ignored)",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrRecordAuth("demo1", "demo2"),
},
})
},
@@ -509,8 +735,8 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
Url: "/my/test/4q1xlclmfloku33",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test/:id",
@@ -528,11 +754,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "expired/invalid token",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test/:id",
@@ -548,14 +774,13 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token (different user)",
Name: "valid record token (different user)",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test/:id",
@@ -571,13 +796,35 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token (owner)",
Name: "valid record token (different collection)",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test/:id",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrOwnerAuth(""),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token (owner)",
Method: http.MethodGet,
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test/:id",
@@ -595,11 +842,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "valid admin token",
Method: http.MethodGet,
Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test/:custom",
@@ -620,3 +867,132 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
scenario.Test(t)
}
}
func TestLoadCollectionContext(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodGet,
Url: "/my/missing",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/demo1",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/demo1",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid admin token",
Method: http.MethodGet,
Url: "/my/demo1",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "mismatched type",
Method: http.MethodGet,
Url: "/my/demo1",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app, "auth"),
},
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "matched type",
Method: http.MethodGet,
Url: "/my/users",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app, "auth"),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+163 -86
View File
@@ -15,13 +15,12 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
// BindRealtimeApi registers the realtime api endpoints.
func BindRealtimeApi(app core.App, rg *echo.Group) {
// bindRealtimeApi registers the realtime api endpoints.
func bindRealtimeApi(app core.App, rg *echo.Group) {
api := realtimeApi{app: app}
subGroup := rg.Group("/realtime", ActivityLogger(app))
@@ -43,26 +42,61 @@ func (api *realtimeApi) connect(c echo.Context) error {
// register new subscription client
client := subscriptions.NewDefaultClient()
api.app.SubscriptionsBroker().Register(client)
defer api.app.SubscriptionsBroker().Unregister(client.Id())
defer func() {
api.app.OnRealtimeDisconnectRequest().Trigger(&core.RealtimeDisconnectEvent{
HttpContext: c,
Client: client,
})
api.app.SubscriptionsBroker().Unregister(client.Id())
}()
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
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")
event := &core.RealtimeConnectEvent{
connectEvent := &core.RealtimeConnectEvent{
HttpContext: c,
Client: client,
}
if err := api.app.OnRealtimeConnectRequest().Trigger(event); err != nil {
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
return err
}
if api.app.IsDebug() {
log.Printf("Realtime connection established: %s\n", client.Id())
}
// signalize established connection (aka. fire "connect" message)
fmt.Fprint(c.Response(), "id:"+client.Id()+"\n")
fmt.Fprint(c.Response(), "event:PB_CONNECT\n")
fmt.Fprint(c.Response(), "data:{\"clientId\":\""+client.Id()+"\"}\n\n")
c.Response().Flush()
connectMsgEvent := &core.RealtimeMessageEvent{
HttpContext: c,
Client: client,
Message: &subscriptions.Message{
Name: "PB_CONNECT",
Data: `{"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.Flush()
return nil
})
if connectMsgErr != nil {
if api.app.IsDebug() {
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
}
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
@@ -82,11 +116,29 @@ func (api *realtimeApi) connect(c echo.Context) error {
return nil
}
w := c.Response()
fmt.Fprint(w, "id:"+client.Id()+"\n")
fmt.Fprint(w, "event:"+msg.Name+"\n")
fmt.Fprint(w, "data:"+msg.Data+"\n\n")
w.Flush()
msgEvent := &core.RealtimeMessageEvent{
HttpContext: c,
Client: client,
Message: &msg,
}
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.Flush()
return nil
})
if msgErr != nil {
if api.app.IsDebug() {
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
}
return nil
}
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
log.Println("OnRealtimeAfterMessageSend error:", err)
}
idleTimer.Stop()
idleTimer.Reset(idleDuration)
@@ -106,25 +158,25 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// read request data
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
// validate request data
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
// find subscription client
client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
if err != nil {
return rest.NewNotFoundError("Missing or invalid client id.", err)
return NewNotFoundError("Missing or invalid client id.", err)
}
// check if the previous request was authorized
oldAuthId := extractAuthIdFromGetter(client)
newAuthId := extractAuthIdFromGetter(c)
if oldAuthId != "" && oldAuthId != newAuthId {
return rest.NewForbiddenError("The current and the previous request authorization don't match.", nil)
return NewForbiddenError("The current and the previous request authorization don't match.", nil)
}
event := &core.RealtimeSubscribeEvent{
@@ -136,7 +188,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
// update auth state
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
e.Client.Set(ContextUserKey, e.HttpContext.Get(ContextUserKey))
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
// unsubscribe from any previous existing subscriptions
e.Client.Unsubscribe()
@@ -154,70 +206,75 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
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() {
client.Set(contextKey, newModel)
}
}
return nil
}
// unregisterClientsByAuthModel unregister all clients that has the provided auth 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() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
}
return nil
}
func (api *realtimeApi) bindEvents() {
userTable := (&models.User{}).TableName()
adminTable := (&models.Admin{}).TableName()
// update user/admin auth state
api.app.OnModelAfterUpdate().Add(func(data *core.ModelEvent) error {
modelTable := data.Model.TableName()
var contextKey string
switch modelTable {
case userTable:
contextKey = ContextUserKey
case adminTable:
contextKey = ContextAdminKey
default:
return nil
// 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() {
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
}
for _, client := range api.app.SubscriptionsBroker().Clients() {
model, _ := client.Get(contextKey).(models.Model)
if model != nil && model.GetId() == data.Model.GetId() {
client.Set(contextKey, data.Model)
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
return api.updateClientsAuthModel(ContextAdminKey, admin)
}
return nil
})
// remove user/admin client(s)
api.app.OnModelAfterDelete().Add(func(data *core.ModelEvent) error {
modelTable := data.Model.TableName()
var contextKey string
switch modelTable {
case userTable:
contextKey = ContextUserKey
case adminTable:
contextKey = ContextAdminKey
default:
return nil
// 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)
}
for _, client := range api.app.SubscriptionsBroker().Clients() {
model, _ := client.Get(contextKey).(models.Model)
if model != nil && model.GetId() == data.Model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
}
return nil
})
api.app.OnRecordAfterCreateRequest().Add(func(data *core.RecordCreateEvent) error {
api.broadcastRecord("create", data.Record)
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("create", record)
}
return nil
})
api.app.OnRecordAfterUpdateRequest().Add(func(data *core.RecordUpdateEvent) error {
api.broadcastRecord("update", data.Record)
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("update", record)
}
return nil
})
api.app.OnRecordAfterDeleteRequest().Add(func(data *core.RecordDeleteEvent) error {
api.broadcastRecord("delete", data.Record)
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("delete", record)
}
return nil
})
}
@@ -240,18 +297,12 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
}
// emulate request data
requestData := map[string]any{
"method": "get",
"query": map[string]any{},
"data": map[string]any{},
"user": nil,
}
user, _ := client.Get(ContextUserKey).(*models.User)
if user != nil {
requestData["user"], _ = user.AsMap()
requestData := &models.RequestData{
Method: "GET",
}
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
@@ -262,7 +313,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
return nil
}
foundRecord, err := api.app.Dao().FindRecordById(record.Collection(), record.Id, ruleFunc)
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
if err == nil && foundRecord != nil {
return true
}
@@ -286,19 +337,29 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return nil // no subscribers
}
// remove the expand from the broadcasted record because we don't
// know if the clients have access to view the expanded records
cleanRecord := *record
cleanRecord.SetExpand(nil)
cleanRecord.WithUnkownData(false)
cleanRecord.IgnoreEmailVisibility(false)
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + record.Id): collection.ViewRule,
(collection.Id + "/" + record.Id): collection.ViewRule,
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
(collection.Name + "/*"): collection.ListRule,
(collection.Id + "/*"): collection.ListRule,
// @deprecated: the same as the wildcard topic but kept for backward compatibility
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
}
recordData := &recordData{
data := &recordData{
Action: action,
Record: record,
Record: &cleanRecord,
}
serializedData, err := json.Marshal(recordData)
dataBytes, err := json.Marshal(data)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
@@ -306,19 +367,35 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return err
}
encodedData := string(dataBytes)
for _, client := range clients {
for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) {
continue
}
if !api.canAccessRecord(client, record, rule) {
if !api.canAccessRecord(client, data.Record, rule) {
continue
}
msg := subscriptions.Message{
Name: subscription,
Data: string(serializedData),
Data: encodedData,
}
// ignore the auth record email visibility checks for
// auth owner, admin or manager
if collection.IsAuth() {
authId := extractAuthIdFromGetter(client)
if authId == data.Record.Id ||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
data.Record.IgnoreEmailVisibility(true) // ignore
if newData, err := json.Marshal(data); err == nil {
msg.Data = string(newData)
}
data.Record.IgnoreEmailVisibility(false) // restore
}
}
client.Channel() <- msg
@@ -333,9 +410,9 @@ type getter interface {
}
func extractAuthIdFromGetter(val getter) string {
user, _ := val.Get(ContextUserKey).(*models.User)
if user != nil {
return user.Id
record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
if record != nil {
return record.Id
}
admin, _ := val.Get(ContextAdminKey).(*models.Admin)
+92 -41
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -10,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
@@ -25,9 +27,58 @@ func TestRealtimeConnect(t *testing.T) {
`data:{"clientId":`,
},
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
},
},
{
Name: "PB_CONNECT interrupt",
Method: http.MethodGet,
Url: "/api/realtime",
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
if e.Message.Name == "PB_CONNECT" {
return errors.New("PB_CONNECT error")
}
return nil
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
},
},
{
Name: "Skipping/ignoring messages",
Method: http.MethodGet,
Url: "/api/realtime",
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
return hook.StopPropagation
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -46,7 +97,7 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() {
client.Unsubscribe()
client.Set(apis.ContextAdminKey, nil)
client.Set(apis.ContextUserKey, nil)
client.Set(apis.ContextAuthRecordKey, nil)
}
scenarios := []tests.ApiScenario{
@@ -68,11 +119,11 @@ func TestRealtimeSubscribe(t *testing.T) {
"OnRealtimeBeforeSubscribeRequest": 1,
"OnRealtimeAfterSubscribeRequest": 1,
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(client.Subscriptions()) != 0 {
t.Errorf("Expected no subscriptions, got %v", client.Subscriptions())
}
@@ -89,11 +140,11 @@ func TestRealtimeSubscribe(t *testing.T) {
"OnRealtimeBeforeSubscribeRequest": 1,
"OnRealtimeAfterSubscribeRequest": 1,
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
expectedSubs := []string{"test1", "test2"}
if len(expectedSubs) != len(client.Subscriptions()) {
t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions())
@@ -113,17 +164,17 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRealtimeBeforeSubscribeRequest": 1,
"OnRealtimeAfterSubscribeRequest": 1,
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(client)
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if admin == nil {
t.Errorf("Expected admin auth model, got nil")
@@ -132,25 +183,25 @@ func TestRealtimeSubscribe(t *testing.T) {
},
},
{
Name: "existing client - authorized user",
Name: "existing client - authorized record",
Method: http.MethodPost,
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRealtimeBeforeSubscribeRequest": 1,
"OnRealtimeAfterSubscribeRequest": 1,
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.SubscriptionsBroker().Register(client)
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, _ := client.Get(apis.ContextUserKey).(*models.User)
if user == nil {
t.Errorf("Expected user auth model, got nil")
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -161,21 +212,21 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
initialAuth := &models.User{}
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
initialAuth := &models.Record{}
initialAuth.RefreshId()
client.Set(apis.ContextUserKey, initialAuth)
client.Set(apis.ContextAuthRecordKey, initialAuth)
app.SubscriptionsBroker().Register(client)
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, _ := client.Get(apis.ContextUserKey).(*models.User)
if user == nil {
t.Errorf("Expected user auth model, got nil")
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -187,55 +238,55 @@ func TestRealtimeSubscribe(t *testing.T) {
}
}
func TestRealtimeUserDeleteEvent(t *testing.T) {
func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
user, err := testApp.Dao().FindUserByEmail("test@example.com")
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextUserKey, user)
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user})
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
}
}
func TestRealtimeUserUpdateEvent(t *testing.T) {
func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
user1, err := testApp.Dao().FindUserByEmail("test@example.com")
authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextUserKey, user1)
client.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
// refetch the user and change its email
user2, err := testApp.Dao().FindUserByEmail("test@example.com")
// refetch the authRecord and change its email
authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2.Email = "new@example.com"
authRecord2.SetEmail("new@example.com")
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user2})
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
clientUser, _ := client.Get(apis.ContextUserKey).(*models.User)
if clientUser.Email != user2.Email {
t.Fatalf("Expected user with email %q, got %q", user2.Email, clientUser.Email)
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != authRecord2.Email() {
t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
}
}
@@ -276,7 +327,7 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
client.Set(apis.ContextAdminKey, admin1)
testApp.SubscriptionsBroker().Register(client)
// refetch the user and change its email
// refetch the authRecord and change its email
admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
@@ -287,6 +338,6 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email {
t.Fatalf("Expected user with email %q, got %q", admin2.Email, clientAdmin.Email)
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
}
}
-432
View File
@@ -1,432 +0,0 @@
package apis
import (
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
const expandQueryParam = "expand"
// BindRecordApi registers the record api endpoints and the corresponding handlers.
func BindRecordApi(app core.App, rg *echo.Group) {
api := recordApi{app: app}
subGroup := rg.Group(
"/collections/:collection/records",
ActivityLogger(app),
LoadCollectionContext(app),
)
subGroup.GET("", api.list)
subGroup.POST("", api.create)
subGroup.GET("/:id", api.view)
subGroup.PATCH("/:id", api.update)
subGroup.DELETE("/:id", api.delete)
}
type recordApi struct {
app core.App
}
func (api *recordApi) list(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
}
// forbid user/guest defined non-relational joins (aka. @collection.*)
queryStr := c.QueryString()
if admin == nil && queryStr != "" && (strings.Contains(queryStr, "@collection") || strings.Contains(queryStr, "%40collection")) {
return rest.NewForbiddenError("Only admins can filter by @collection.", nil)
}
requestData := api.exportRequestData(c)
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection))
if admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(queryStr, &rawRecords)
if err != nil {
return rest.NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
// expand records relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
expandErr := api.app.Dao().ExpandRecords(
records,
expands,
api.expandFunc(c, requestData),
)
if expandErr != nil && api.app.IsDebug() {
log.Println("Failed to expand relations: ", expandErr)
}
}
result.Items = records
event := &core.RecordsListEvent{
HttpContext: c,
Collection: collection,
Records: records,
Result: result,
}
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
func (api *recordApi) view(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return rest.NewNotFoundError("", nil)
}
requestData := api.exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return rest.NewNotFoundError("", fetchErr)
}
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
expandErr := api.app.Dao().ExpandRecord(
record,
expands,
api.expandFunc(c, requestData),
)
if expandErr != nil && api.app.IsDebug() {
log.Println("Failed to expand relations: ", expandErr)
}
}
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
}
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
func (api *recordApi) create(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
}
requestData := api.exportRequestData(c)
// temporary save the record and check it against the create rule
if admin == nil && collection.CreateRule != nil && *collection.CreateRule != "" {
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
testRecord := models.NewRecord(collection)
testForm := forms.NewRecordUpsert(api.app, testRecord)
if err := testForm.LoadData(c.Request()); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
}
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
_, fetchErr := txDao.FindRecordById(collection, testRecord.Id, ruleFunc)
return fetchErr
})
if testErr != nil {
return rest.NewBadRequestError("Failed to create record.", testErr)
}
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(api.app, record)
// load request
if err := form.LoadData(c.Request()); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
}
event := &core.RecordCreateEvent{
HttpContext: c,
Record: record,
}
handlerErr := api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
// create the record
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to create record.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
if handlerErr == nil {
api.app.OnRecordAfterCreateRequest().Trigger(event)
}
return handlerErr
}
func (api *recordApi) update(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return rest.NewNotFoundError("", nil)
}
requestData := api.exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
// fetch record
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return rest.NewNotFoundError("", fetchErr)
}
form := forms.NewRecordUpsert(api.app, record)
// load request
if err := form.LoadData(c.Request()); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
}
event := &core.RecordUpdateEvent{
HttpContext: c,
Record: record,
}
handlerErr := api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
// update the record
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to update record.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
if handlerErr == nil {
api.app.OnRecordAfterUpdateRequest().Trigger(event)
}
return handlerErr
}
func (api *recordApi) delete(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return rest.NewNotFoundError("", nil)
}
requestData := api.exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return rest.NewNotFoundError("", fetchErr)
}
event := &core.RecordDeleteEvent{
HttpContext: c,
Record: record,
}
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
// delete the record
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
return rest.NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
}
// try to delete the record files
if err := api.deleteRecordFiles(e.Record); err != nil && api.app.IsDebug() {
// non critical error - only log for debug
// (usually could happen due to S3 api limits)
log.Println(err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
if handlerErr == nil {
api.app.OnRecordAfterDeleteRequest().Trigger(event)
}
return handlerErr
}
func (api *recordApi) deleteRecordFiles(record *models.Record) error {
fs, err := api.app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
failed := fs.DeletePrefix(record.BaseFilesPath())
if len(failed) > 0 {
return fmt.Errorf("Failed to delete %d record files.", len(failed))
}
return nil
}
func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
result := map[string]any{}
queryParams := map[string]any{}
bodyData := map[string]any{}
method := c.Request().Method
echo.BindQueryParams(c, &queryParams)
rest.BindBody(c, &bodyData)
result["method"] = method
result["query"] = queryParams
result["data"] = bodyData
result["user"] = nil
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
if loggedUser != nil {
result["user"], _ = loggedUser.AsMap()
}
return result
}
func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
return api.app.Dao().FindRecordsByIds(relCollection, relIds, func(q *dbx.SelectQuery) error {
if admin != nil {
return nil // admin can access everything
}
if relCollection.ViewRule == nil {
return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
}
if *relCollection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), relCollection, requestData)
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
})
}
}
+587
View File
@@ -0,0 +1,587 @@
package apis
import (
"errors"
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// bindRecordAuthApi registers the auth record api endpoints and
// the corresponding handlers.
func bindRecordAuthApi(app core.App, rg *echo.Group) {
api := recordAuthApi{app: app}
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) // allow anyone so that we can link the OAuth2 profile with the authenticated record
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/request-verification", api.requestVerification)
subGroup.POST("/confirm-verification", api.confirmVerification)
subGroup.POST("/request-email-change", api.requestEmailChange, RequireSameContextRecordAuth())
subGroup.POST("/confirm-email-change", api.confirmEmailChange)
subGroup.GET("/records/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
subGroup.DELETE("/records/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
}
type recordAuthApi struct {
app core.App
}
func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record, meta any) error {
token, tokenErr := tokens.NewRecordAuthToken(api.app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.RecordAuthEvent{
HttpContext: c,
Record: authRecord,
Token: token,
Meta: meta,
}
return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
// expand record relations
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
failed := api.app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(api.app.Dao(), &requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
result := map[string]any{
"token": e.Token,
"record": e.Record,
}
if e.Meta != nil {
result["meta"] = e.Meta
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
func (api *recordAuthApi) authRefresh(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewNotFoundError("Missing auth record context.", nil)
}
return api.authResponse(c, record, nil)
}
type providerInfo struct {
Name string `json:"name"`
State string `json:"state"`
CodeVerifier string `json:"codeVerifier"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
AuthUrl string `json:"authUrl"`
}
func (api *recordAuthApi) authMethods(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
authOptions := collection.AuthOptions()
result := struct {
UsernamePassword bool `json:"usernamePassword"`
EmailPassword bool `json:"emailPassword"`
AuthProviders []providerInfo `json:"authProviders"`
}{
UsernamePassword: authOptions.AllowUsernameAuth,
EmailPassword: authOptions.AllowEmailAuth,
AuthProviders: []providerInfo{},
}
if !authOptions.AllowOAuth2Auth {
return c.JSON(http.StatusOK, result)
}
nameConfigMap := api.app.Settings().NamedAuthProviderConfigs()
for name, config := range nameConfigMap {
if !config.Enabled {
continue
}
provider, err := auth.NewProviderByName(name)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
}
continue // skip provider
}
if err := config.SetupProvider(provider); err != nil {
if api.app.IsDebug() {
log.Println(err)
}
continue // skip provider
}
state := security.RandomString(30)
codeVerifier := security.RandomString(43)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
})
}
return c.JSON(http.StatusOK, result)
}
func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
if !collection.AuthOptions().AllowOAuth2Auth {
return NewBadRequestError("The collection is not configured to allow OAuth2 authentication.", nil)
}
var fallbackAuthRecord *models.Record
loggedAuthRecord, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if loggedAuthRecord != nil && loggedAuthRecord.Collection().Id == collection.Id {
fallbackAuthRecord = loggedAuthRecord
}
form := forms.NewRecordOAuth2Login(api.app, collection, fallbackAuthRecord)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, authData, submitErr := form.Submit(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
createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // either admin or the rule is empty
}
if collection.CreateRule == nil {
return errors.New("Only admins can create new accounts with OAuth2")
}
if *collection.CreateRule != "" {
resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
return fmt.Errorf("Failed create rule constraint: %w", err)
}
return nil
})
})
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, record, authData)
}
func (api *recordAuthApi) authWithPassword(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordPasswordLogin(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, record, nil)
}
func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
authOptions := collection.AuthOptions()
if !authOptions.AllowUsernameAuth && !authOptions.AllowEmailAuth {
return NewBadRequestError("The collection is not configured to allow password authentication.", nil)
}
form := forms.NewRecordPasswordResetRequest(api.app, collection)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return NewBadRequestError("An error occurred while validating the form.", err)
}
event := &core.RecordRequestPasswordResetEvent{
HttpContext: c,
}
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event)
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
}
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordPasswordResetConfirm(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmPasswordResetEvent{
HttpContext: c,
}
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) requestVerification(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordVerificationRequest(api.app, collection)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return NewBadRequestError("An error occurred while validating the form.", err)
}
event := &core.RecordRequestVerificationEvent{
HttpContext: c,
}
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterRequestVerificationRequest().Trigger(event)
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
}
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordVerificationConfirm(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmVerificationEvent{
HttpContext: c,
}
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires valid auth record.", nil)
}
form := forms.NewRecordEmailChangeRequest(api.app, record)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.RecordRequestEmailChangeEvent{
HttpContext: c,
Record: record,
}
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
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)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordEmailChangeConfirm(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := &core.RecordConfirmEmailChangeEvent{
HttpContext: c,
}
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to confirm email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
id := c.PathParam("id")
if id == "" {
return NewNotFoundError("", nil)
}
record, err := api.app.Dao().FindRecordById(collection.Id, id)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
}
event := &core.RecordListExternalAuthsEvent{
HttpContext: c,
Record: record,
ExternalAuths: externalAuths,
}
return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
})
}
func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
id := c.PathParam("id")
provider := c.PathParam("provider")
if id == "" || provider == "" {
return NewNotFoundError("", nil)
}
record, err := api.app.Dao().FindRecordById(collection.Id, id)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
externalAuth, err := api.app.Dao().FindExternalAuthByRecordAndProvider(record, provider)
if err != nil {
return NewNotFoundError("Missing external auth provider relation.", err)
}
event := &core.RecordUnlinkExternalAuthEvent{
HttpContext: c,
Record: record,
ExternalAuth: externalAuth,
}
handlerErr := 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)
})
if handlerErr == nil {
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
}
return handlerErr
}
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
package apis
import (
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/search"
)
const expandQueryParam = "expand"
// bindRecordCrudApi registers the record crud api endpoints and
// the corresponding handlers.
func bindRecordCrudApi(app core.App, rg *echo.Group) {
api := recordApi{app: app}
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app),
)
subGroup.GET("/records", api.list)
subGroup.POST("/records", api.create)
subGroup.GET("/records/:id", api.view)
subGroup.PATCH("/records/:id", api.update)
subGroup.DELETE("/records/:id", api.delete)
}
type recordApi struct {
app core.App
}
func (api *recordApi) list(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("", "Missing collection context.")
}
// forbid users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil {
return err
}
requestData := RequestData(c)
if requestData.Admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
fieldsResolver := resolvers.NewRecordFieldResolver(
api.app.Dao(),
collection,
requestData,
// hidden fields are searchable only by admins
requestData.Admin != nil,
)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection))
if requestData.Admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
if err != nil {
return NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
result.Items = records
event := &core.RecordsListEvent{
HttpContext: c,
Collection: collection,
Records: records,
Result: result,
}
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
func (api *recordApi) view(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("", "Missing collection context.")
}
recordId := c.PathParam("id")
if recordId == "" {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
if requestData.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)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return NewNotFoundError("", fetchErr)
}
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
}
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
func (api *recordApi) create(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("", "Missing collection context.")
}
requestData := RequestData(c)
if requestData.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
// temporary save the record and check it against the create rule
if requestData.Admin == nil && collection.CreateRule != nil {
createRuleFunc := func(q *dbx.SelectQuery) error {
if *collection.CreateRule == "" {
return nil // no create rule to resolve
}
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
testRecord := models.NewRecord(collection)
testForm := forms.NewRecordUpsert(api.app, testRecord)
testForm.SetFullManageAccess(true)
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
if err != nil {
return fmt.Errorf("DrySubmit create rule failure: %w", err)
}
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
return nil
})
if testErr != nil {
return NewBadRequestError("Failed to create record.", testErr)
}
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(api.app, record)
form.SetFullManageAccess(hasFullManageAccess)
// load request
if err := form.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordCreateEvent{
HttpContext: c,
Record: record,
}
// create the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if err := next(); err != nil {
return NewBadRequestError("Failed to create record.", err)
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterCreateRequest().Trigger(event)
}
return submitErr
}
func (api *recordApi) update(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("", "Missing collection context.")
}
recordId := c.PathParam("id")
if recordId == "" {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
if requestData.Admin == nil && collection.UpdateRule == 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.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
// fetch record
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return NewNotFoundError("", fetchErr)
}
form := forms.NewRecordUpsert(api.app, record)
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
// load request
if err := form.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordUpdateEvent{
HttpContext: c,
Record: record,
}
// update the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if err := next(); err != nil {
return NewBadRequestError("Failed to update record.", err)
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterUpdateRequest().Trigger(event)
}
return submitErr
}
func (api *recordApi) delete(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("", "Missing collection context.")
}
recordId := c.PathParam("id")
if recordId == "" {
return NewNotFoundError("", nil)
}
requestData := RequestData(c)
if requestData.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)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return NewNotFoundError("", fetchErr)
}
event := &core.RecordDeleteEvent{
HttpContext: c,
Record: record,
}
handlerErr := 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)
})
if handlerErr == nil {
api.app.OnRecordAfterDeleteRequest().Trigger(event)
}
return handlerErr
}
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // admins are allowed to query everything
}
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
forbiddenFields := []string{"@collection.", "@request."}
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
package apis
import (
"fmt"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
const ContextRequestDataKey = "requestData"
// Deprecated: Will be removed after v0.9. Use apis.RequestData(c) instead.
func GetRequestData(c echo.Context) *models.RequestData {
return RequestData(c)
}
// RequestData exports cached common request data fields
// (query, body, logged auth state, etc.) from the provided context.
func RequestData(c echo.Context) *models.RequestData {
// return cached to avoid copying the body multiple times
if v := c.Get(ContextRequestDataKey); v != nil {
if data, ok := v.(*models.RequestData); ok {
return data
}
}
result := &models.RequestData{
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
}
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
result.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
echo.BindQueryParams(c, &result.Query)
rest.BindBody(c, &result.Data)
c.Set(ContextRequestDataKey, result)
return result
}
// EnrichRecord parses the request context and enrich the provided record:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth record and its expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
}
// EnrichRecords parses the request context and enriches the provided records:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - 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)
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
return fmt.Errorf("Failed to resolve email visibility: %w", err)
}
expands := defaultExpands
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
if len(expands) == 0 {
return nil // nothing to expand
}
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
if len(errs) > 0 {
return fmt.Errorf("Failed to expand: %v", errs)
}
return nil
}
// expandFetch is the records fetch function that is used to expand related records.
func expandFetch(
dao *daos.Dao,
requestData *models.RequestData,
) 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 {
return nil // admins can access everything
}
if relCollection.ViewRule == nil {
return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
}
if *relCollection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
})
if err == nil && len(records) > 0 {
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
}
return records, err
}
}
// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for
// the provided record if the current auth model is admin, owner or a "manager".
//
// Note: Expects all records to be from the same auth collection!
func autoIgnoreAuthRecordsEmailVisibility(
dao *daos.Dao,
records []*models.Record,
requestData *models.RequestData,
) error {
if len(records) == 0 || !records[0].Collection().IsAuth() {
return nil // nothing to check
}
if requestData.Admin != nil {
for _, rec := range records {
rec.IgnoreEmailVisibility(true)
}
return nil
}
collection := records[0].Collection()
mappedRecords := make(map[string]*models.Record, len(records))
recordIds := make([]any, len(records))
for i, rec := range records {
mappedRecords[rec.Id] = rec
recordIds[i] = rec.Id
}
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
}
authOptions := collection.AuthOptions()
if authOptions.ManageRule == nil || *authOptions.ManageRule == "" {
return nil // no manage rule to check
}
// fetch the ids of the managed records
// ---
managedIds := []string{}
query := dao.RecordQuery(collection).
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(query)
query.AndWhere(expr)
if err := query.Column(&managedIds); err != nil {
return err
}
// ---
// ignore the email visibility check for the managed records
for _, id := range managedIds {
if rec, ok := mappedRecords[id]; ok {
rec.IgnoreEmailVisibility(true)
}
}
return nil
}
// hasAuthManageAccess checks whether the client is allowed to have full
// [forms.RecordUpsert] auth management permissions
// (aka. allowing to change system auth fields without oldPassword).
func hasAuthManageAccess(
dao *daos.Dao,
record *models.Record,
requestData *models.RequestData,
) bool {
if !record.Collection().IsAuth() {
return false
}
manageRule := record.Collection().AuthOptions().ManageRule
if manageRule == nil || *manageRule == "" {
return false // only for admins (manageRule can't be empty)
}
if requestData == nil || requestData.AuthRecord == nil {
return false // no auth record
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
_, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
return findErr == nil
}
+101
View File
@@ -0,0 +1,101 @@
package apis_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestRequestData(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
dummyRecord := &models.Record{}
dummyRecord.Id = "id1"
c.Set(apis.ContextAuthRecordKey, dummyRecord)
dummyAdmin := &models.Admin{}
dummyAdmin.Id = "id2"
c.Set(apis.ContextAdminKey, dummyAdmin)
result := apis.RequestData(c)
if result == nil {
t.Fatal("Expected *models.RequestData instance, got nil")
}
if result.Method != http.MethodPost {
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
}
rawQuery, _ := json.Marshal(result.Query)
expectedQuery := `{"test":"123"}`
if v := string(rawQuery); v != expectedQuery {
t.Fatalf("Expected Query %v, got %v", expectedQuery, v)
}
rawData, _ := json.Marshal(result.Data)
expectedData := `{"test":456}`
if v := string(rawData); v != expectedData {
t.Fatalf("Expected Data %v, got %v", expectedData, v)
}
if result.AuthRecord == nil || result.AuthRecord.Id != dummyRecord.Id {
t.Fatalf("Expected AuthRecord %v, got %v", dummyRecord, result.AuthRecord)
}
if result.Admin == nil || result.Admin.Id != dummyAdmin.Id {
t.Fatalf("Expected Admin %v, got %v", dummyAdmin, result.Admin)
}
}
func TestEnrichRecords(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
dummyAdmin := &models.Admin{}
dummyAdmin.Id = "test_id"
c.Set(apis.ContextAdminKey, dummyAdmin)
app, _ := tests.NewTestApp()
defer app.Cleanup()
records, err := app.Dao().FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"})
if err != nil {
t.Fatal(err)
}
apis.EnrichRecords(c, app.Dao(), records, "rel_one")
for _, record := range records {
expand := record.Expand()
if len(expand) == 0 {
t.Fatalf("Expected non-empty expand, got nil for record %v", record)
}
if len(record.GetStringSlice("rel_one")) != 0 {
if _, ok := expand["rel_one"]; !ok {
t.Fatalf("Expected rel_one to be expanded for record %v, got \n%v", record, expand)
}
}
if len(record.GetStringSlice("rel_many")) != 0 {
if _, ok := expand["rel_many"]; !ok {
t.Fatalf("Expected rel_many to be expanded for record %v, got \n%v", record, expand)
}
}
}
}
-914
View File
@@ -1,914 +0,0 @@
package apis_test
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/tests"
)
func TestRecordsList(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodGet,
Url: "/api/collections/missing/records",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
Method: http.MethodGet,
Url: "/api/collections/demo/records",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
Method: http.MethodGet,
Url: "/api/collections/demo/records",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection but with admin only filter/sort (aka. @collection)",
Method: http.MethodGet,
Url: "/api/collections/demo3/records?filter=@collection.demo.title='test'",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "public collection but with ENCODED admin only filter/sort (aka. @collection)",
Method: http.MethodGet,
Url: "/api/collections/demo3/records?filter=%40collection.demo.title%3D%27test%27",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin trying to access nil rule collection (aka. need admin auth)",
Method: http.MethodGet,
Url: "/api/collections/demo/records",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":3`,
`"items":[{`,
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "public collection",
Method: http.MethodGet,
Url: "/api/collections/demo3/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":1`,
`"items":[{`,
`"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "using the collection id as identifier",
Method: http.MethodGet,
Url: "/api/collections/3cd6fe92-70dc-4819-8542-4d036faabd89/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":1`,
`"items":[{`,
`"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "valid query params",
Method: http.MethodGet,
Url: "/api/collections/demo/records?filter=title%7E%27test%27&sort=-title",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":2`,
`"items":[{`,
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "invalid filter",
Method: http.MethodGet,
Url: "/api/collections/demo/records?filter=invalid~'test'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "expand",
Method: http.MethodGet,
Url: "/api/collections/demo2/records?expand=manyrels,onerel&perPage=2&sort=created",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":2`,
`"totalItems":2`,
`"items":[{`,
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"manyrels":[{`,
`"manyrels":[]`,
`"rel_cascade":"`,
`"rel_cascade":null`,
`"onerel":{"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc","@collectionName":"demo",`,
`"json":[1,2,3]`,
`"select":["a","b"]`,
`"select":[]`,
`"user":null`,
`"bool":true`,
`"number":456`,
`"user":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "authorized as user that DOESN'T match the collection list rule",
Method: http.MethodGet,
Url: "/api/collections/demo2/records",
RequestHeaders: map[string]string{
// test@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "authorized as user that matches the collection list rule",
Method: http.MethodGet,
Url: "/api/collections/demo2/records",
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":2`,
`"items":[{`,
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
},
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRecordView(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodGet,
Url: "/api/collections/missing/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record (unauthorized)",
Method: http.MethodGet,
Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "invalid record id (authorized)",
Method: http.MethodGet,
Url: "/api/collections/demo/records/invalid",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record (authorized)",
Method: http.MethodGet,
Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "mismatched collection-record pair (unauthorized)",
Method: http.MethodGet,
Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "mismatched collection-record pair (authorized)",
Method: http.MethodGet,
Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
Method: http.MethodGet,
Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
Method: http.MethodGet,
Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "access record as admin",
Method: http.MethodGet,
Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"@collectionName":"demo"`,
`"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "access record as admin (using the collection id as identifier)",
Method: http.MethodGet,
Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"@collectionName":"demo"`,
`"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "access record as admin (test rule skipping)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
`"@collectionName":"demo2"`,
`"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
`"manyrels":[]`,
`"onerel":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "access record as user (filter mismatch)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "access record as user (filter match)",
Method: http.MethodGet,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
`"@collectionName":"demo2"`,
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "expand relations",
Method: http.MethodGet,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=manyrels,onerel",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
`"@collectionName":"demo2"`,
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"manyrels":[{`,
`"onerel":{`,
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"@collectionName":"demo"`,
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
},
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRecordDelete(t *testing.T) {
ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
entries, _ := os.ReadDir(storageDir)
if len(entries) != 0 {
t.Errorf("Expected empty/deleted dir, found %d", len(entries))
}
}
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodDelete,
Url: "/api/collections/missing/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record (unauthorized)",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record (authorized)",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "mismatched collection-record pair (unauthorized)",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "mismatched collection-record pair (authorized)",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "access record as admin",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnRecordAfterDeleteRequest": 1,
"OnModelAfterUpdate": 1, // nullify related record
"OnModelBeforeUpdate": 1, // nullify related record
"OnModelBeforeDelete": 2, // +1 cascade delete related record
"OnModelAfterDelete": 2, // +1 cascade delete related record
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
},
},
{
Name: "access record as admin (using the collection id as identifier)",
Method: http.MethodDelete,
Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/577bd676-aacb-4072-b7da-99d00ee210a4",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnRecordAfterDeleteRequest": 1,
"OnModelAfterUpdate": 1, // nullify related record
"OnModelBeforeUpdate": 1, // nullify related record
"OnModelBeforeDelete": 2, // +1 cascade delete related record
"OnModelAfterDelete": 2, // +1 cascade delete related record
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
},
},
{
Name: "deleting record as admin (test rule skipping)",
Method: http.MethodDelete,
Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnRecordAfterDeleteRequest": 1,
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
},
},
{
Name: "deleting record as user (filter mismatch)",
Method: http.MethodDelete,
Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "deleting record as user (filter match)",
Method: http.MethodDelete,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnRecordAfterDeleteRequest": 1,
"OnModelBeforeDelete": 1,
"OnModelAfterDelete": 1,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
},
},
{
Name: "trying to delete record while being part of a non-cascade required relation",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/848a1dea-5ddd-42d6-a00d-030547bffcfe",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
},
},
{
Name: "cascade delete referenced records",
Method: http.MethodDelete,
Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnRecordAfterDeleteRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
recId := "63c2ab80-84ab-4057-a592-4604a731f78f"
col, _ := app.Dao().FindCollectionByNameOrId("demo2")
rec, _ := app.Dao().FindRecordById(col, recId, nil)
if rec != nil {
t.Errorf("Expected record %s to be cascade deleted", recId)
}
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRecordCreate(t *testing.T) {
formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "new",
}, "file")
if err != nil {
t.Fatal(err)
}
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodPost,
Url: "/api/collections/missing/records",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "guest trying to access nil-rule collection",
Method: http.MethodPost,
Url: "/api/collections/demo/records",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "user trying to access nil-rule collection",
Method: http.MethodPost,
Url: "/api/collections/demo/records",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit invalid format",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{"`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: nil,
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "guest submit in public collection",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{"title":"new"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"title":"new"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
{
Name: "user submit in restricted collection (rule failure check)",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"rel_cascade": "577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels": ["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text": "test123",
"bool": "false"
}`),
RequestHeaders: map[string]string{
// test@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "user submit in restricted collection (rule pass check)",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text":"test123",
"bool":true
}`),
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"text":"test123"`,
`"bool":true`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
{
Name: "admin submit in restricted collection (rule skip check)",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text":"test123",
"bool":false
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"text":"test123"`,
`"bool":false`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
{
Name: "submit via multipart form data",
Method: http.MethodPost,
Url: "/api/collections/demo/records",
Body: formData,
RequestHeaders: map[string]string{
"Content-Type": mp.FormDataContentType(),
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"`,
`"title":"new"`,
`"file":"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRecordUpdate(t *testing.T) {
formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "new",
}, "file")
if err != nil {
t.Fatal(err)
}
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodPatch,
Url: "/api/collections/missing/records/2c542824-9de1-42fe-8924-e57c86267760",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record",
Method: http.MethodPatch,
Url: "/api/collections/demo3/records/00000000-9de1-42fe-8924-e57c86267760",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "guest trying to edit nil-rule collection record",
Method: http.MethodPatch,
Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "user trying to edit nil-rule collection record",
Method: http.MethodPatch,
Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit invalid format",
Method: http.MethodPatch,
Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
Body: strings.NewReader(`{"`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "submit nil body",
Method: http.MethodPatch,
Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
Body: nil,
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "guest submit in public collection",
Method: http.MethodPatch,
Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
Body: strings.NewReader(`{"title":"new"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
`"title":"new"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "user submit in restricted collection (rule failure check)",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
Body: strings.NewReader(`{"text": "test_new"}`),
RequestHeaders: map[string]string{
// test@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "user submit in restricted collection (rule pass check)",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
Body: strings.NewReader(`{
"text":"test_new",
"bool":false
}`),
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"bool":false`,
`"text":"test_new"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "admin submit in restricted collection (rule skip check)",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
Body: strings.NewReader(`{
"text":"test_new"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"text":"test_new"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "submit via multipart form data",
Method: http.MethodPatch,
Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
Body: formData,
RequestHeaders: map[string]string{
"Content-Type": mp.FormDataContentType(),
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
`"title":"new"`,
`"file":"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+72 -16
View File
@@ -3,19 +3,22 @@ package apis
import (
"net/http"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
)
// BindSettingsApi registers the settings api endpoints.
func BindSettingsApi(app core.App, rg *echo.Group) {
// bindSettingsApi registers the settings api endpoints.
func bindSettingsApi(app core.App, rg *echo.Group) {
api := settingsApi{app: app}
subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth())
subGroup.GET("", api.list)
subGroup.PATCH("", api.set)
subGroup.POST("/test/s3", api.testS3)
subGroup.POST("/test/email", api.testEmail)
}
type settingsApi struct {
@@ -25,7 +28,7 @@ type settingsApi struct {
func (api *settingsApi) list(c echo.Context) error {
settings, err := api.app.Settings().RedactClone()
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
event := &core.SettingsListEvent{
@@ -40,8 +43,10 @@ func (api *settingsApi) list(c echo.Context) error {
func (api *settingsApi) set(c echo.Context) error {
form := forms.NewSettingsUpsert(api.app)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.SettingsUpdateEvent{
@@ -50,22 +55,73 @@ func (api *settingsApi) set(c echo.Context) error {
NewSettings: form.Settings,
}
handlerErr := api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("An error occurred while submitting the form.", err)
}
// update the settings
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return rest.NewBadRequestError("", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return NewBadRequestError("", err)
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnSettingsAfterUpdateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *settingsApi) testS3(c echo.Context) error {
if !api.app.Settings().S3.Enabled {
return NewBadRequestError("S3 storage is not enabled.", nil)
}
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()
testFileKey := "pb_test_" + security.PseudorandomString(5) + "/test.txt"
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
if err := fs.Delete(testFileKey); err != nil {
return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
}
func (api *settingsApi) testEmail(c echo.Context) error {
form := forms.NewTestEmailSend(api.app)
// load request
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
// send
if err := form.Submit(); err != nil {
if fErr, ok := err.(validation.Errors); ok {
// form error
return NewBadRequestError("Failed to send the test email.", fErr)
}
// mailer error
return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
}
+230 -30
View File
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/tests"
)
@@ -18,11 +19,11 @@ func TestSettingsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -32,7 +33,7 @@ func TestSettingsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -42,15 +43,21 @@ func TestSettingsList(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"userAuthToken":{`,
`"userPasswordResetToken":{`,
`"userEmailChangeToken":{`,
`"userVerificationToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
`"twitterAuth":{`,
`"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -66,7 +73,7 @@ func TestSettingsList(t *testing.T) {
}
func TestSettingsSet(t *testing.T) {
validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}`
validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{
{
@@ -78,12 +85,12 @@ func TestSettingsSet(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -94,7 +101,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -104,19 +111,23 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"userAuthToken":{`,
`"userPasswordResetToken":{`,
`"userEmailChangeToken":{`,
`"userVerificationToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
`"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"Acme"`,
`"minPasswordLength":8`,
`"appName":"acme_test"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -129,18 +140,14 @@ func TestSettingsSet(t *testing.T) {
Name: "authorized as admin submitting invalid data",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`),
Body: strings.NewReader(`{"meta":{"appName":""}}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"emailAuth":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required","message":"Must be no less than 5."}}`,
`"meta":{"appName":{"code":"validation_required","message":"Cannot be blank."}}`,
},
ExpectedEvents: map[string]int{
"OnSettingsBeforeUpdateRequest": 1,
`"meta":{"appName":{"code":"validation_required"`,
},
},
{
@@ -149,7 +156,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -159,19 +166,24 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"userAuthToken":{`,
`"userPasswordResetToken":{`,
`"userEmailChangeToken":{`,
`"userVerificationToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
`"twitterAuth":{`,
`"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
`"minPasswordLength":12`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -186,3 +198,191 @@ func TestSettingsSet(t *testing.T) {
scenario.Test(t)
}
}
func TestSettingsTestS3(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (no s3)",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestSettingsTestEmail(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "verification",
"email": "test@example.com"
}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "verification",
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (invalid body)",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (empty json)",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"email":{"code":"validation_required"`,
`"template":{"code":"validation_required"`,
},
},
{
Name: "authorized as admin (verifiation template)",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "verification",
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
}
},
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnMailerBeforeRecordVerificationSend": 1,
"OnMailerAfterRecordVerificationSend": 1,
},
},
{
Name: "authorized as admin (password reset template)",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "password-reset",
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
}
},
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnMailerBeforeRecordResetPasswordSend": 1,
"OnMailerAfterRecordResetPasswordSend": 1,
},
},
{
Name: "authorized as admin (email change)",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "email-change",
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
}
},
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnMailerBeforeRecordChangeEmailSend": 1,
"OnMailerAfterRecordChangeEmailSend": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
-444
View File
@@ -1,444 +0,0 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// BindUserApi registers the user api endpoints and the corresponding handlers.
func BindUserApi(app core.App, rg *echo.Group) {
api := userApi{app: app}
subGroup := rg.Group("/users", ActivityLogger(app))
subGroup.GET("/auth-methods", api.authMethods)
subGroup.POST("/auth-via-oauth2", api.oauth2Auth, RequireGuestOnly())
subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/request-verification", api.requestVerification)
subGroup.POST("/confirm-verification", api.confirmVerification)
subGroup.POST("/request-email-change", api.requestEmailChange, RequireUserAuth())
subGroup.POST("/confirm-email-change", api.confirmEmailChange)
subGroup.POST("/refresh", api.refresh, RequireUserAuth())
// crud
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create)
subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id"))
subGroup.PATCH("/:id", api.update, RequireAdminAuth())
subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id"))
}
type userApi struct {
app core.App
}
func (api *userApi) authResponse(c echo.Context, user *models.User, meta any) error {
token, tokenErr := tokens.NewUserAuthToken(api.app, user)
if tokenErr != nil {
return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.UserAuthEvent{
HttpContext: c,
User: user,
Token: token,
Meta: meta,
}
return api.app.OnUserAuthRequest().Trigger(event, func(e *core.UserAuthEvent) error {
result := map[string]any{
"token": e.Token,
"user": e.User,
}
if e.Meta != nil {
result["meta"] = e.Meta
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
func (api *userApi) refresh(c echo.Context) error {
user, _ := c.Get(ContextUserKey).(*models.User)
if user == nil {
return rest.NewNotFoundError("Missing auth user context.", nil)
}
return api.authResponse(c, user, nil)
}
type providerInfo struct {
Name string `json:"name"`
State string `json:"state"`
CodeVerifier string `json:"codeVerifier"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
AuthUrl string `json:"authUrl"`
}
func (api *userApi) authMethods(c echo.Context) error {
result := struct {
EmailPassword bool `json:"emailPassword"`
AuthProviders []providerInfo `json:"authProviders"`
}{
EmailPassword: true,
AuthProviders: []providerInfo{},
}
settings := api.app.Settings()
result.EmailPassword = settings.EmailAuth.Enabled
nameConfigMap := settings.NamedAuthProviderConfigs()
for name, config := range nameConfigMap {
if !config.Enabled {
continue
}
provider, err := auth.NewProviderByName(name)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
}
// skip provider
continue
}
if err := config.SetupProvider(provider); err != nil {
if api.app.IsDebug() {
log.Println(err)
}
// skip provider
continue
}
state := security.RandomString(30)
codeVerifier := security.RandomString(30)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
})
}
return c.JSON(http.StatusOK, result)
}
func (api *userApi) oauth2Auth(c echo.Context) error {
form := forms.NewUserOauth2Login(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, authData, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, user, authData)
}
func (api *userApi) emailAuth(c echo.Context) error {
if !api.app.Settings().EmailAuth.Enabled {
return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
}
form := forms.NewUserEmailLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, user, nil)
}
func (api *userApi) requestPasswordReset(c echo.Context) error {
form := forms.NewUserPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return c.NoContent(http.StatusNoContent)
}
func (api *userApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewUserPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, user, nil)
}
func (api *userApi) requestEmailChange(c echo.Context) error {
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
if loggedUser == nil {
return rest.NewUnauthorizedError("The request requires valid authorized user.", nil)
}
form := forms.NewUserEmailChangeRequest(api.app, loggedUser)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to request email change.", err)
}
return c.NoContent(http.StatusNoContent)
}
func (api *userApi) confirmEmailChange(c echo.Context) error {
form := forms.NewUserEmailChangeConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to confirm email change.", submitErr)
}
return api.authResponse(c, user, nil)
}
func (api *userApi) requestVerification(c echo.Context) error {
form := forms.NewUserVerificationRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return c.NoContent(http.StatusNoContent)
}
func (api *userApi) confirmVerification(c echo.Context) error {
form := forms.NewUserVerificationConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("An error occurred while submitting the form.", submitErr)
}
return api.authResponse(c, user, nil)
}
// -------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------
func (api *userApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(
"id", "created", "updated", "email", "verified",
)
users := []*models.User{}
result, searchErr := search.NewProvider(fieldResolver).
Query(api.app.Dao().UserQuery()).
ParseAndExec(c.QueryString(), &users)
if searchErr != nil {
return rest.NewBadRequestError("", searchErr)
}
// eager load user profiles (if any)
if err := api.app.Dao().LoadProfiles(users); err != nil {
return rest.NewBadRequestError("", err)
}
event := &core.UsersListEvent{
HttpContext: c,
Users: users,
Result: result,
}
return api.app.OnUsersListRequest().Trigger(event, func(e *core.UsersListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
func (api *userApi) view(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
event := &core.UserViewEvent{
HttpContext: c,
User: user,
}
return api.app.OnUserViewRequest().Trigger(event, func(e *core.UserViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
func (api *userApi) create(c echo.Context) error {
if !api.app.Settings().EmailAuth.Enabled {
return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
}
user := &models.User{}
form := forms.NewUserUpsert(api.app, user)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
}
event := &core.UserCreateEvent{
HttpContext: c,
User: user,
}
handlerErr := api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
// create the user
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to create user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
})
if handlerErr == nil {
api.app.OnUserAfterCreateRequest().Trigger(event)
}
return handlerErr
}
func (api *userApi) update(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
form := forms.NewUserUpsert(api.app, user)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
}
event := &core.UserUpdateEvent{
HttpContext: c,
User: user,
}
handlerErr := api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
// update the user
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to update user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
})
if handlerErr == nil {
api.app.OnUserAfterUpdateRequest().Trigger(event)
}
return handlerErr
}
func (api *userApi) delete(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
event := &core.UserDeleteEvent{
HttpContext: c,
User: user,
}
handlerErr := api.app.OnUserBeforeDeleteRequest().Trigger(event, func(e *core.UserDeleteEvent) error {
// delete the user model
if err := api.app.Dao().DeleteUser(e.User); err != nil {
return rest.NewBadRequestError("Failed to delete user. Make sure that the user is not part of a required relation reference.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
if handlerErr == nil {
api.app.OnUserAfterDeleteRequest().Trigger(event)
}
return handlerErr
}
-900
View File
@@ -1,900 +0,0 @@
package apis_test
import (
"net/http"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/tests"
)
func TestUsersAuthMethods(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Method: http.MethodGet,
Url: "/api/users/auth-methods",
ExpectedStatus: 200,
ExpectedContent: []string{
`"emailPassword":true`,
`"authProviders":[{`,
`"authProviders":[{`,
`"name":"gitlab"`,
`"state":`,
`"codeVerifier":`,
`"codeChallenge":`,
`"codeChallengeMethod":`,
`"authUrl":`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserEmailAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "authorized as user",
Method: http.MethodPost,
Url: "/api/users/auth-via-email",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin",
Method: http.MethodPost,
Url: "/api/users/auth-via-email",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "invalid body format",
Method: http.MethodPost,
Url: "/api/users/auth-via-email",
Body: strings.NewReader(`{"email`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "invalid data",
Method: http.MethodPost,
Url: "/api/users/auth-via-email",
Body: strings.NewReader(`{"email":"","password":""}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{`,
`"password":{`,
},
},
{
Name: "disabled email/pass auth with valid data",
Method: http.MethodPost,
Url: "/api/users/auth-via-email",
Body: strings.NewReader(`{"email":"test@example.com","password":"123456"}`),
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.Settings().EmailAuth.Enabled = false
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid data",
Method: http.MethodPost,
Url: "/api/users/auth-via-email",
Body: strings.NewReader(`{"email":"test2@example.com","password":"123456"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token"`,
`"user"`,
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
`"email":"test2@example.com"`,
`"verified":false`, // unverified user should be able to authenticate
},
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserRequestPasswordReset(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
},
{
Name: "invalid data",
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(`{"email`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing user",
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(`{"email":"missing@example.com"}`),
ExpectedStatus: 204,
},
{
Name: "existing user",
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
ExpectedStatus: 204,
// usually this events are fired but since the submit is
// executed in a separate go routine they are fired async
// ExpectedEvents: map[string]int{
// "OnModelBeforeUpdate": 1,
// "OnModelAfterUpdate": 1,
// "OnMailerBeforeUserResetPasswordSend": 1,
// "OnMailerAfterUserResetPasswordSend": 1,
// },
},
{
Name: "existing user (after already sent)",
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
ExpectedStatus: 204,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserConfirmPasswordReset(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
Method: http.MethodPost,
Url: "/api/users/confirm-password-reset",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"password":{"code":"validation_required","message":"Cannot be blank."},"passwordConfirm":{"code":"validation_required","message":"Cannot be blank."},"token":{"code":"validation_required","message":"Cannot be blank."}}`},
},
{
Name: "invalid data format",
Method: http.MethodPost,
Url: "/api/users/confirm-password-reset",
Body: strings.NewReader(`{"password`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "expired token",
Method: http.MethodPost,
Url: "/api/users/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQxMDMxMjAwfQ.t2lVe0ny9XruQsSFQdXqBi0I85i6vIUAQjFXZY5HPxc","password":"123456789","passwordConfirm":"123456789"}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"token":{`,
`"code":"validation_invalid_token"`,
},
},
{
Name: "valid token and data",
Method: http.MethodPost,
Url: "/api/users/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTU2MDAwfQ.V1gEbY4caEIF6IhQAJ8KZD4RvOGvTCFuYg1fTRSvhe0","password":"123456789","passwordConfirm":"123456789"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"user":`,
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserRequestVerification(t *testing.T) {
scenarios := []tests.ApiScenario{
// empty data
{
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
},
// invalid data
{
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
// missing user
{
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email":"missing@example.com"}`),
ExpectedStatus: 204,
},
// existing already verified user
{
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email":"test@example.com"}`),
ExpectedStatus: 204,
},
// existing unverified user
{
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email":"test2@example.com"}`),
ExpectedStatus: 204,
// usually this events are fired but since the submit is
// executed in a separate go routine they are fired async
// ExpectedEvents: map[string]int{
// "OnModelBeforeUpdate": 1,
// "OnModelAfterUpdate": 1,
// "OnMailerBeforeUserVerificationSend": 1,
// "OnMailerAfterUserVerificationSend": 1,
// },
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserConfirmVerification(t *testing.T) {
scenarios := []tests.ApiScenario{
// empty data
{
Method: http.MethodPost,
Url: "/api/users/confirm-verification",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":`,
`"token":{"code":"validation_required"`,
},
},
// invalid data
{
Method: http.MethodPost,
Url: "/api/users/confirm-verification",
Body: strings.NewReader(`{"token`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
// expired token
{
Method: http.MethodPost,
Url: "/api/users/confirm-verification",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTY0MTAzMTIwMH0.YCqyREksfqn7cWu-innNNTbWQCr9DgYr7dduM2wxrtQ"}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"token":{`,
`"code":"validation_invalid_token"`,
},
},
// valid token
{
Method: http.MethodPost,
Url: "/api/users/confirm-verification",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTk1NjAwMH0.OsxRKuZrNTnwyVjvCwB4jY8TbT-NPZ-UFCpRhCvuv2U"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"user":`,
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
`"email":"test2@example.com"`,
`"verified":true`,
},
ExpectedEvents: map[string]int{
"OnUserAuthRequest": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserRequestEmailChange(t *testing.T) {
scenarios := []tests.ApiScenario{
// unauthorized
{
Method: http.MethodPost,
Url: "/api/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
// authorized as admin
{
Method: http.MethodPost,
Url: "/api/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
// invalid data
{
Method: http.MethodPost,
Url: "/api/users/request-email-change",
Body: strings.NewReader(`{"newEmail`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
// empty data
{
Method: http.MethodPost,
Url: "/api/users/request-email-change",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":`,
`"newEmail":{"code":"validation_required"`,
},
},
// valid data (existing email)
{
Method: http.MethodPost,
Url: "/api/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"test2@example.com"}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":`,
`"newEmail":{"code":"validation_user_email_exists"`,
},
},
// valid data (new email)
{
Method: http.MethodPost,
Url: "/api/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnMailerBeforeUserChangeEmailSend": 1,
"OnMailerAfterUserChangeEmailSend": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserConfirmEmailChange(t *testing.T) {
scenarios := []tests.ApiScenario{
// empty data
{
Method: http.MethodPost,
Url: "/api/users/confirm-email-change",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":`,
`"token":{"code":"validation_required"`,
`"password":{"code":"validation_required"`,
},
},
// invalid data
{
Method: http.MethodPost,
Url: "/api/users/confirm-email-change",
Body: strings.NewReader(`{"token`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
// expired token and correct password
{
Method: http.MethodPost,
Url: "/api/users/confirm-email-change",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjAwfQ.DOqNtSDcXbWix8OsK13X-tjfWi6jZNlAzIZiwG_YDOs","password":"123456"}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"token":{`,
`"code":"validation_invalid_token"`,
},
},
// valid token and incorrect password
{
Method: http.MethodPost,
Url: "/api/users/confirm-email-change",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"654321"}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"password":{`,
`"code":"validation_invalid_password"`,
},
},
// valid token and correct password
{
Method: http.MethodPost,
Url: "/api/users/confirm-email-change",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"123456"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"user":`,
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
`"email":"change@example.com"`,
`"verified":true`,
},
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserRefresh(t *testing.T) {
scenarios := []tests.ApiScenario{
// unauthorized
{
Method: http.MethodPost,
Url: "/api/users/refresh",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
// authorized as admin
{
Method: http.MethodPost,
Url: "/api/users/refresh",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
// authorized as user
{
Method: http.MethodPost,
Url: "/api/users/refresh",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"user":`,
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
},
ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUsersList(t *testing.T) {
scenarios := []tests.ApiScenario{
// unauthorized
{
Method: http.MethodGet,
Url: "/api/users",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
// authorized as user
{
Method: http.MethodGet,
Url: "/api/users",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
// authorized as admin
{
Method: http.MethodGet,
Url: "/api/users",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":3`,
`"items":[{`,
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
`"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
},
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
},
// authorized as admin + paging and sorting
{
Method: http.MethodGet,
Url: "/api/users?page=2&perPage=2&sort=-created",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
`"totalItems":3`,
`"items":[{`,
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
},
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
},
// authorized as admin + invalid filter
{
Method: http.MethodGet,
Url: "/api/users?filter=invalidfield~'test2'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
// authorized as admin + valid filter
{
Method: http.MethodGet,
Url: "/api/users?filter=verified=true",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":2`,
`"items":[{`,
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
`"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
},
ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserView(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting user id",
Method: http.MethodGet,
Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + existing user id",
Method: http.MethodGet,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
},
ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
},
{
Name: "authorized as user - trying to view another user",
Method: http.MethodGet,
Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user - owner",
Method: http.MethodGet,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
},
ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserDelete(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodDelete,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting user id",
Method: http.MethodDelete,
Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + existing user id",
Method: http.MethodDelete,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnUserBeforeDeleteRequest": 1,
"OnUserAfterDeleteRequest": 1,
"OnModelBeforeDelete": 2, // cascade delete to related Record model
"OnModelAfterDelete": 2, // cascade delete to related Record model
},
},
{
Name: "authorized as user - trying to delete another user",
Method: http.MethodDelete,
Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user - owner",
Method: http.MethodDelete,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnUserBeforeDeleteRequest": 1,
"OnUserAfterDeleteRequest": 1,
"OnModelBeforeDelete": 2, // cascade delete to related Record model
"OnModelAfterDelete": 2, // cascade delete to related Record model
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserCreate(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
Method: http.MethodPost,
Url: "/api/users",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{"code":"validation_required"`,
`"password":{"code":"validation_required"`,
},
ExpectedEvents: map[string]int{
"OnUserBeforeCreateRequest": 1,
},
},
{
Name: "invalid data",
Method: http.MethodPost,
Url: "/api/users",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321"}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{"code":"validation_user_email_exists"`,
`"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
},
ExpectedEvents: map[string]int{
"OnUserBeforeCreateRequest": 1,
},
},
{
Name: "valid data but with disabled email/pass auth",
Method: http.MethodPost,
Url: "/api/users",
Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.Settings().EmailAuth.Enabled = false
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid data",
Method: http.MethodPost,
Url: "/api/users",
Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"email":"newuser@example.com"`,
},
ExpectedEvents: map[string]int{
"OnUserBeforeCreateRequest": 1,
"OnUserAfterCreateRequest": 1,
"OnModelBeforeCreate": 2, // +1 for the created profile record
"OnModelAfterCreate": 2, // +1 for the created profile record
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUserUpdate(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPatch,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Body: strings.NewReader(`{"email":"new@example.com"}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user (owner)",
Method: http.MethodPatch,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Body: strings.NewReader(`{"email":"new@example.com"}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin - invalid/missing user id",
Method: http.MethodPatch,
Url: "/api/users/invalid",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin - empty data",
Method: http.MethodPatch,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
`"email":"test@example.com"`,
},
ExpectedEvents: map[string]int{
"OnUserBeforeUpdateRequest": 1,
"OnUserAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "authorized as admin - invalid data",
Method: http.MethodPatch,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Body: strings.NewReader(`{"email":"test2@example.com"}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"email":{"code":"validation_user_email_exists"`,
},
ExpectedEvents: map[string]int{
"OnUserBeforeUpdateRequest": 1,
},
},
{
Name: "authorized as admin - valid data",
Method: http.MethodPatch,
Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Body: strings.NewReader(`{"email":"new@example.com"}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
`"email":"new@example.com"`,
},
ExpectedEvents: map[string]int{
"OnUserBeforeUpdateRequest": 1,
"OnUserAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
-77
View File
@@ -1,77 +0,0 @@
package cmd
import (
"log"
"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/migrate"
"github.com/spf13/cobra"
)
// NewMigrateCommand creates and returns new command for handling DB migrations.
func NewMigrateCommand(app core.App) *cobra.Command {
desc := `
Supported arguments are:
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create folder name - creates new migration template file.
`
var databaseFlag string
command := &cobra.Command{
Use: "migrate",
Short: "Executes DB migration scripts",
ValidArgs: []string{"up", "down", "create"},
Long: desc,
Run: func(command *cobra.Command, args []string) {
// normalize
if databaseFlag != "logs" {
databaseFlag = "db"
}
connections := migrationsConnectionsMap(app)
runner, err := migrate.NewRunner(
connections[databaseFlag].DB,
connections[databaseFlag].MigrationsList,
)
if err != nil {
log.Fatal(err)
}
if err := runner.Run(args...); err != nil {
log.Fatal(err)
}
},
}
command.PersistentFlags().StringVar(
&databaseFlag,
"database",
"db",
"specify the database connection to use (db or logs)",
)
return command
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func migrationsConnectionsMap(app core.App) map[string]migrationsConnection {
return map[string]migrationsConnection{
"db": {
DB: app.DB(),
MigrationsList: migrations.AppMigrations,
},
"logs": {
DB: app.LogsDB(),
MigrationsList: logs.LogsMigrations,
},
}
}
+24 -7
View File
@@ -2,7 +2,6 @@ package cmd
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
@@ -11,8 +10,11 @@ import (
"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"
@@ -28,7 +30,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command := &cobra.Command{
Use: "serve",
Short: "Starts the web server (default to localhost:8090)",
Short: "Starts the web server (default to 127.0.0.1:8090)",
Run: func(command *cobra.Command, args []string) {
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
@@ -39,7 +41,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
// (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("WARNING: Settings load error! \n%v", err)
color.Yellow("Fallback to the application defaults.")
color.Yellow("=====================================")
}
@@ -87,10 +89,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
if httpsAddr != "" {
schema = "https"
}
regular := color.New()
bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr))
fmt.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
fmt.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
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
@@ -123,7 +126,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command.PersistentFlags().StringVar(
&httpAddr,
"http",
"localhost:8090",
"127.0.0.1:8090",
"api HTTP server address",
)
@@ -137,8 +140,22 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
return command
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func runMigrations(app core.App) error {
connections := migrationsConnectionsMap(app)
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)
+444
View File
@@ -0,0 +1,444 @@
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
}
-21
View File
@@ -1,21 +0,0 @@
// Package cmd implements various PocketBase system commands.
package cmd
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"github.com/spf13/cobra"
)
// NewVersionCommand creates and returns new command that prints
// the current PocketBase version.
func NewVersionCommand(app core.App, version string) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Prints the current PocketBase app version",
Run: func(command *cobra.Command, args []string) {
fmt.Printf("PocketBase v%s\n", version)
},
}
}
+162 -89
View File
@@ -6,6 +6,7 @@ package core
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
@@ -47,7 +48,7 @@ type App interface {
IsDebug() bool
// Settings returns the loaded app settings.
Settings() *Settings
Settings() *settings.Settings
// Cache returns the app internal cache store.
Cache() *store.Store[any]
@@ -79,10 +80,28 @@ type App interface {
// App event hooks
// ---------------------------------------------------------------
// OnBeforeBootstrap hook is triggered before initializing the base
// application resources (eg. before db open and initial settings load).
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
// OnAfterBootstrap hook is triggered after initializing the base
// 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.
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]).
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
// OnAfterApiError hook is triggered right after sending an error API
// response to the client.
// It could be used to log the final API error in external services.
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
// ---------------------------------------------------------------
// Dao event hooks
// ---------------------------------------------------------------
@@ -119,45 +138,45 @@ type App interface {
// 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.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerAfterAdminResetPasswordSend hook is triggered after
// admin password reset email was successfully sent.
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerBeforeUserResetPasswordSend hook is triggered right before
// sending a password reset email to a user.
// 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.
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
// OnMailerAfterUserResetPasswordSend hook is triggered after
// a user password reset email was successfully sent.
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterRecordResetPasswordSend hook is triggered after
// an auth record password reset email was successfully sent.
OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
// OnMailerBeforeUserVerificationSend hook is triggered right before
// sending a verification email to a user.
// 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.
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
// OnMailerAfterUserVerificationSend hook is triggered after a user
// verification email was successfully sent.
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterRecordVerificationSend hook is triggered after a
// verification email was successfully sent to an auth record.
OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
// OnMailerBeforeUserChangeEmailSend hook is triggered right before
// sending a confirmation new address email to a a user.
// 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.
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
// change address email was successfully sent.
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterRecordChangeEmailSend hook is triggered after a
// verification email was successfully sent to an auth record.
OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
// ---------------------------------------------------------------
// Realtime API event hooks
@@ -167,6 +186,21 @@ type App interface {
// the SSE client connection.
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
// OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted
// SSE client connection.
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
// OnRealtimeBeforeMessage hook is triggered right before sending
// an SSE message to a client.
//
// Returning [hook.StopPropagation] will prevent sending the message.
// Returning any other non-nil error will close the realtime connection.
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeMessage hook is triggered right after sending
// an SSE message to a client.
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeSubscribeRequest hook is triggered before changing
// the client subscriptions, allowing you to further validate and
// modify the submitted change.
@@ -192,7 +226,7 @@ type App interface {
//
// Could be used to additionally validate the request data or
// implement completely different persistence behavior
// (returning hook.StopPropagation).
// (returning [hook.StopPropagation]).
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
// OnSettingsAfterUpdateRequest hook is triggered after each
@@ -227,7 +261,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 (returning [hook.StopPropagation]).
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
// OnAdminAfterCreateRequest hook is triggered after each
@@ -238,7 +272,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 (returning [hook.StopPropagation]).
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
// OnAdminAfterUpdateRequest hook is triggered after each
@@ -249,7 +283,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 (returning [hook.StopPropagation]).
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
// OnAdminAfterDeleteRequest hook is triggered after each
@@ -264,72 +298,100 @@ type App interface {
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
// ---------------------------------------------------------------
// User API event hooks
// Record Auth API event hooks
// ---------------------------------------------------------------
// OnUsersListRequest hook is triggered on each API Users list request.
// OnRecordAuthRequest hook is triggered on each successful API
// record authentication request (sign-in, token refresh, etc.).
//
// Could be used to additionally validate or modify the authenticated
// record data and token.
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
// 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]).
OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
// successful request password reset API request.
OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
// OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record
// 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]).
OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
// successful confirm password reset API request.
OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
// OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
// 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]).
OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
// OnRecordAfterRequestVerificationRequest hook is triggered after each
// successful request verification API request.
OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
// OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record
// 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]).
OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
// successful confirm verification API request.
OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
// OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request
// (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]).
OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
// successful request email change API request.
OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
// OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record
// 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]).
OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
// successful confirm email change API request.
OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*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.
OnUsersListRequest() *hook.Hook[*UsersListEvent]
OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent]
// OnUserViewRequest hook is triggered on each API User view request.
//
// Could be used to validate or modify the response before returning it to the client.
OnUserViewRequest() *hook.Hook[*UserViewEvent]
// OnUserBeforeCreateRequest hook is triggered before each API User
// create request (after request data load and before model persistence).
// 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 persistence behavior (returning hook.StopPropagation).
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
// completely different delete behavior (returning [hook.StopPropagation]).
OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
// OnUserAfterCreateRequest hook is triggered after each
// successful API User create request.
OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]
// OnUserBeforeUpdateRequest hook is triggered before each API User
// 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).
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
// OnUserAfterUpdateRequest hook is triggered after each
// successful API User update request.
OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]
// OnUserBeforeDeleteRequest hook is triggered before each API User
// 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).
OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]
// OnUserAfterDeleteRequest hook is triggered after each
// successful API User delete request.
OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent]
// OnUserAuthRequest hook is triggered on each successful API User
// authentication request (sign-in, token refresh, etc.).
//
// Could be used to additionally validate or modify the
// authenticated user data and token.
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
// OnUserBeforeOauth2Register hook is triggered before each User OAuth2
// authentication request (when the client config has enabled new users registration).
//
// Could be used to additionally validate or modify the new user
// before persisting in the DB.
OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent]
// OnUserAfterOauth2Register hook is triggered after each successful User
// OAuth2 authentication sign-up request (right after the new user persistence).
OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent]
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API record external auth unlink request.
OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record API event hooks
// Record CRUD API event hooks
// ---------------------------------------------------------------
// OnRecordsListRequest hook is triggered on each API Records list request.
@@ -346,7 +408,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 (returning [hook.StopPropagation]).
OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
// OnRecordAfterCreateRequest hook is triggered after each
@@ -357,7 +419,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 (returning [hook.StopPropagation]).
OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
// OnRecordAfterUpdateRequest hook is triggered after each
@@ -368,7 +430,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 (returning [hook.StopPropagation]).
OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
// OnRecordAfterDeleteRequest hook is triggered after each
@@ -393,7 +455,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 (returning [hook.StopPropagation]).
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
// OnCollectionAfterCreateRequest hook is triggered after each
@@ -404,7 +466,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 (returning [hook.StopPropagation]).
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
// OnCollectionAfterUpdateRequest hook is triggered after each
@@ -415,10 +477,21 @@ 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 (returning [hook.StopPropagation]).
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionAfterDeleteRequest hook is triggered after each
// successful API Collection delete request.
OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionsBeforeImportRequest hook is triggered before each API
// 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]).
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
// OnCollectionsAfterImportRequest hook is triggered after each
// successful API collections import request.
OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent]
}
+264 -163
View File
@@ -3,8 +3,8 @@ package core
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"os"
"path/filepath"
"time"
@@ -13,10 +13,10 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
@@ -32,15 +32,19 @@ type BaseApp struct {
// internals
cache *store.Store[any]
settings *Settings
settings *settings.Settings
db *dbx.DB
dao *daos.Dao
logsDB *dbx.DB
logsDao *daos.Dao
subscriptionsBroker *subscriptions.Broker
// serve event hooks
onBeforeServe *hook.Hook[*ServeEvent]
// app event hooks
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
onAfterBootstrap *hook.Hook[*BootstrapEvent]
onBeforeServe *hook.Hook[*ServeEvent]
onBeforeApiError *hook.Hook[*ApiErrorEvent]
onAfterApiError *hook.Hook[*ApiErrorEvent]
// dao event hooks
onModelBeforeCreate *hook.Hook[*ModelEvent]
@@ -51,17 +55,20 @@ type BaseApp struct {
onModelAfterDelete *hook.Hook[*ModelEvent]
// mailer event hooks
onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
onMailerBeforeUserResetPasswordSend *hook.Hook[*MailerUserEvent]
onMailerAfterUserResetPasswordSend *hook.Hook[*MailerUserEvent]
onMailerBeforeUserVerificationSend *hook.Hook[*MailerUserEvent]
onMailerAfterUserVerificationSend *hook.Hook[*MailerUserEvent]
onMailerBeforeUserChangeEmailSend *hook.Hook[*MailerUserEvent]
onMailerAfterUserChangeEmailSend *hook.Hook[*MailerUserEvent]
onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
onMailerBeforeRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
onMailerAfterRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
onMailerBeforeRecordVerificationSend *hook.Hook[*MailerRecordEvent]
onMailerAfterRecordVerificationSend *hook.Hook[*MailerRecordEvent]
onMailerBeforeRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
onMailerAfterRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
// realtime api event hooks
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent]
onRealtimeDisconnectRequest *hook.Hook[*RealtimeDisconnectEvent]
onRealtimeBeforeMessageSend *hook.Hook[*RealtimeMessageEvent]
onRealtimeAfterMessageSend *hook.Hook[*RealtimeMessageEvent]
onRealtimeBeforeSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
onRealtimeAfterSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
@@ -84,20 +91,25 @@ type BaseApp struct {
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
// user api event hooks
onUsersListRequest *hook.Hook[*UsersListEvent]
onUserViewRequest *hook.Hook[*UserViewEvent]
onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent]
onUserAfterCreateRequest *hook.Hook[*UserCreateEvent]
onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent]
onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent]
onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent]
onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent]
onUserAuthRequest *hook.Hook[*UserAuthEvent]
onUserBeforeOauth2Register *hook.Hook[*UserOauth2RegisterEvent]
onUserAfterOauth2Register *hook.Hook[*UserOauth2RegisterEvent]
// record auth API event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
onRecordAfterConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
onRecordBeforeRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
onRecordAfterRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
onRecordBeforeConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
onRecordAfterConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
onRecordBeforeRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
onRecordAfterRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
onRecordBeforeConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
onRecordAfterConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
// record api event hooks
// record crud API event hooks
onRecordsListRequest *hook.Hook[*RecordsListEvent]
onRecordViewRequest *hook.Hook[*RecordViewEvent]
onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent]
@@ -107,32 +119,38 @@ type BaseApp struct {
onRecordBeforeDeleteRequest *hook.Hook[*RecordDeleteEvent]
onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent]
// collection api event hooks
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent]
// collection API event hooks
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionsBeforeImportRequest *hook.Hook[*CollectionsImportEvent]
onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent]
}
// NewBaseApp creates and returns a new BaseApp instance
// configured with the provided arguments.
//
// To initialize the app, you need to call `app.Bootsrap()`.
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
return &BaseApp{
app := &BaseApp{
dataDir: dataDir,
isDebug: isDebug,
encryptionEnv: encryptionEnv,
cache: store.New[any](nil),
settings: NewSettings(),
settings: settings.New(),
subscriptionsBroker: subscriptions.NewBroker(),
// serve event hooks
onBeforeServe: &hook.Hook[*ServeEvent]{},
// app event hooks
onBeforeBootstrap: &hook.Hook[*BootstrapEvent]{},
onAfterBootstrap: &hook.Hook[*BootstrapEvent]{},
onBeforeServe: &hook.Hook[*ServeEvent]{},
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
// dao event hooks
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
@@ -143,17 +161,20 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onModelAfterDelete: &hook.Hook[*ModelEvent]{},
// mailer event hooks
onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
onMailerBeforeUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
onMailerAfterUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
onMailerBeforeUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
onMailerAfterUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
onMailerBeforeUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
onMailerAfterUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
onMailerBeforeRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
onMailerAfterRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
onMailerBeforeRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
onMailerAfterRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
onMailerBeforeRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
onMailerAfterRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
// realtime API event hooks
onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{},
onRealtimeDisconnectRequest: &hook.Hook[*RealtimeDisconnectEvent]{},
onRealtimeBeforeMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
onRealtimeAfterMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
onRealtimeBeforeSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
onRealtimeAfterSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
@@ -176,20 +197,25 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
// user API event hooks
onUsersListRequest: &hook.Hook[*UsersListEvent]{},
onUserViewRequest: &hook.Hook[*UserViewEvent]{},
onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{},
onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{},
onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
onUserAuthRequest: &hook.Hook[*UserAuthEvent]{},
onUserBeforeOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{},
onUserAfterOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{},
// record auth API event hooks
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
onRecordAfterConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
onRecordBeforeRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
onRecordAfterRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
onRecordBeforeConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
onRecordAfterConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
onRecordBeforeRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
onRecordAfterRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
onRecordBeforeConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
onRecordAfterConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
// record API event hooks
// record crud API event hooks
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
onRecordViewRequest: &hook.Hook[*RecordViewEvent]{},
onRecordBeforeCreateRequest: &hook.Hook[*RecordCreateEvent]{},
@@ -200,20 +226,32 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onRecordAfterDeleteRequest: &hook.Hook[*RecordDeleteEvent]{},
// collection API event hooks
onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{},
onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{},
onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{},
onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{},
onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionsBeforeImportRequest: &hook.Hook[*CollectionsImportEvent]{},
onCollectionsAfterImportRequest: &hook.Hook[*CollectionsImportEvent]{},
}
app.registerDefaultHooks()
return app
}
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.)
func (app *BaseApp) Bootstrap() error {
event := &BootstrapEvent{app}
if err := app.OnBeforeBootstrap().Trigger(event); err != nil {
return err
}
// clear resources of previous core state (if any)
if err := app.ResetBootstrapState(); err != nil {
return err
@@ -232,10 +270,13 @@ func (app *BaseApp) Bootstrap() error {
return err
}
// we don't check for an error because the db migrations may
// have not been executed yet.
// we don't check for an error because the db migrations may have not been executed yet
app.RefreshSettings()
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
}
return nil
}
@@ -299,7 +340,7 @@ func (app *BaseApp) IsDebug() bool {
}
// Settings returns the loaded app settings.
func (app *BaseApp) Settings() *Settings {
func (app *BaseApp) Settings() *settings.Settings {
return app.settings
}
@@ -342,6 +383,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
app.settings.S3.Endpoint,
app.settings.S3.AccessKey,
app.settings.S3.Secret,
app.settings.S3.ForcePathStyle,
)
}
@@ -352,71 +394,53 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
// RefreshSettings reinitializes and reloads the stored application settings.
func (app *BaseApp) RefreshSettings() error {
if app.settings == nil {
app.settings = NewSettings()
app.settings = settings.New()
}
encryptionKey := os.Getenv(app.EncryptionEnv())
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
storedSettings, err := app.Dao().FindSettings(encryptionKey)
if err != nil && err != sql.ErrNoRows {
return err
}
if param == nil {
// no settings were previously stored
return app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
// no settings were previously stored
if storedSettings == nil {
return app.Dao().SaveSettings(app.settings, encryptionKey)
}
// load the settings from the stored param into the app ones
// ---
newSettings := NewSettings()
// try first without decryption
plainDecodeErr := json.Unmarshal(param.Value, newSettings)
// failed, try to decrypt
if plainDecodeErr != nil {
// load without decrypt has failed and there is no encryption key to use for decrypt
if encryptionKey == "" {
return errors.New("Failed to load the stored app settings (missing or invalid encryption key).")
}
// decrypt
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
if decryptErr != nil {
return decryptErr
}
// decode again
decryptedDecodeErr := json.Unmarshal(decrypted, newSettings)
if decryptedDecodeErr != nil {
return decryptedDecodeErr
}
}
if err := app.settings.Merge(newSettings); err != nil {
if err := app.settings.Merge(storedSettings); err != nil {
return err
}
if plainDecodeErr == nil && encryptionKey != "" {
// save because previously the settings weren't stored encrypted
saveErr := app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
if saveErr != nil {
return saveErr
}
}
return nil
}
// -------------------------------------------------------------------
// Serve event hooks
// App event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnBeforeBootstrap() *hook.Hook[*BootstrapEvent] {
return app.onBeforeBootstrap
}
func (app *BaseApp) OnAfterBootstrap() *hook.Hook[*BootstrapEvent] {
return app.onAfterBootstrap
}
func (app *BaseApp) OnBeforeServe() *hook.Hook[*ServeEvent] {
return app.onBeforeServe
}
func (app *BaseApp) OnBeforeApiError() *hook.Hook[*ApiErrorEvent] {
return app.onBeforeApiError
}
func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
return app.onAfterApiError
}
// -------------------------------------------------------------------
// Dao event hooks
// -------------------------------------------------------------------
@@ -457,28 +481,28 @@ func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdmi
return app.onMailerAfterAdminResetPasswordSend
}
func (app *BaseApp) OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
return app.onMailerBeforeUserResetPasswordSend
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordResetPasswordSend
}
func (app *BaseApp) OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
return app.onMailerAfterUserResetPasswordSend
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordResetPasswordSend
}
func (app *BaseApp) OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] {
return app.onMailerBeforeUserVerificationSend
func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordVerificationSend
}
func (app *BaseApp) OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent] {
return app.onMailerAfterUserVerificationSend
func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordVerificationSend
}
func (app *BaseApp) OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
return app.onMailerBeforeUserChangeEmailSend
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerBeforeRecordChangeEmailSend
}
func (app *BaseApp) OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
return app.onMailerAfterUserChangeEmailSend
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
return app.onMailerAfterRecordChangeEmailSend
}
// -------------------------------------------------------------------
@@ -489,6 +513,18 @@ func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
return app.onRealtimeConnectRequest
}
func (app *BaseApp) OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] {
return app.onRealtimeDisconnectRequest
}
func (app *BaseApp) OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] {
return app.onRealtimeBeforeMessageSend
}
func (app *BaseApp) OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] {
return app.onRealtimeAfterMessageSend
}
func (app *BaseApp) OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] {
return app.onRealtimeBeforeSubscribeRequest
}
@@ -562,55 +598,75 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
}
// -------------------------------------------------------------------
// User API event hooks
// Record auth API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnUsersListRequest() *hook.Hook[*UsersListEvent] {
return app.onUsersListRequest
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
return app.onRecordAuthRequest
}
func (app *BaseApp) OnUserViewRequest() *hook.Hook[*UserViewEvent] {
return app.onUserViewRequest
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordBeforeRequestPasswordResetRequest
}
func (app *BaseApp) OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] {
return app.onUserBeforeCreateRequest
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordAfterRequestPasswordResetRequest
}
func (app *BaseApp) OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent] {
return app.onUserAfterCreateRequest
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordBeforeConfirmPasswordResetRequest
}
func (app *BaseApp) OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] {
return app.onUserBeforeUpdateRequest
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordAfterConfirmPasswordResetRequest
}
func (app *BaseApp) OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent] {
return app.onUserAfterUpdateRequest
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordBeforeRequestVerificationRequest
}
func (app *BaseApp) OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent] {
return app.onUserBeforeDeleteRequest
func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordAfterRequestVerificationRequest
}
func (app *BaseApp) OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent] {
return app.onUserAfterDeleteRequest
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordBeforeConfirmVerificationRequest
}
func (app *BaseApp) OnUserAuthRequest() *hook.Hook[*UserAuthEvent] {
return app.onUserAuthRequest
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordAfterConfirmVerificationRequest
}
func (app *BaseApp) OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] {
return app.onUserBeforeOauth2Register
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordBeforeRequestEmailChangeRequest
}
func (app *BaseApp) OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] {
return app.onUserAfterOauth2Register
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordAfterRequestEmailChangeRequest
}
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordBeforeConfirmEmailChangeRequest
}
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordAfterConfirmEmailChangeRequest
}
func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
return app.onRecordListExternalAuthsRequest
}
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
return app.onRecordBeforeUnlinkExternalAuthRequest
}
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
return app.onRecordAfterUnlinkExternalAuthRequest
}
// -------------------------------------------------------------------
// Record API event hooks
// Record CRUD API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] {
@@ -681,6 +737,14 @@ func (app *BaseApp) OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDelet
return app.onCollectionAfterDeleteRequest
}
func (app *BaseApp) OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] {
return app.onCollectionsBeforeImportRequest
}
func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] {
return app.onCollectionsAfterImportRequest
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
@@ -692,7 +756,7 @@ func (app *BaseApp) initLogsDB() error {
return connectErr
}
app.logsDao = app.createDao(app.logsDB)
app.logsDao = daos.New(app.logsDB)
return nil
}
@@ -704,24 +768,22 @@ func (app *BaseApp) initDataDB() error {
return connectErr
}
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
if app.IsDebug() {
if app.IsDebug() {
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
}
app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
if app.IsDebug() {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
}
app.dao = app.createDao(app.db)
app.dao = app.createDaoWithHooks(app.db)
return nil
}
func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao {
func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
dao := daos.New(db)
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
@@ -729,7 +791,10 @@ func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao {
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
@@ -737,7 +802,10 @@ func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao {
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
}
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
@@ -745,8 +813,41 @@ func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao {
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
}
return dao
}
func (app *BaseApp) registerDefaultHooks() {
deletePrefix := func(prefix string) error {
fs, err := app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
failed := fs.DeletePrefix(prefix)
if len(failed) > 0 {
return errors.New("Failed to delete the files at " + prefix)
}
return nil
}
// delete storage files from deleted Collection, Records, etc.
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
}
return nil
})
}
+63
View File
@@ -0,0 +1,63 @@
package core_test
import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestBaseAppRefreshSettings(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// cleanup all stored settings
if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil {
t.Fatalf("Failed to delete all test settings: %v", err)
}
// check if the new settings are saved in the db
app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil {
t.Fatal("Failed to refresh the settings after delete")
}
testEventCalls(t, app, map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
})
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
if err != nil {
t.Fatalf("Expected new settings to be persisted, got %v", err)
}
// change the db entry and refresh the app settings (ensure that there was no db update)
param.Value = types.JsonRaw([]byte(`{"example": 123}`))
if err := app.Dao().SaveParam(param.Key, param.Value); err != nil {
t.Fatalf("Failed to update the test settings: %v", err)
}
app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil {
t.Fatalf("Failed to refresh the app settings: %v", err)
}
testEventCalls(t, app, nil)
// try to refresh again without doing any changes
app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil {
t.Fatalf("Failed to refresh the app settings without change: %v", err)
}
testEventCalls(t, app, nil)
}
func testEventCalls(t *testing.T, app *tests.TestApp, events map[string]int) {
if len(events) != len(app.EventCalls) {
t.Fatalf("Expected events doesn't match: \n%v, \ngot \n%v", events, app.EventCalls)
}
for name, total := range events {
if v, ok := app.EventCalls[name]; !ok || v != total {
t.Fatalf("Expected events doesn't exist or match: \n%v, \ngot \n%v", events, app.EventCalls)
}
}
}
+38 -34
View File
@@ -195,28 +195,28 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend)
}
if app.onMailerBeforeUserResetPasswordSend != app.OnMailerBeforeUserResetPasswordSend() || app.OnMailerBeforeUserResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserResetPasswordSend(), app.onMailerBeforeUserResetPasswordSend)
if app.onMailerBeforeRecordResetPasswordSend != app.OnMailerBeforeRecordResetPasswordSend() || app.OnMailerBeforeRecordResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordResetPasswordSend(), app.onMailerBeforeRecordResetPasswordSend)
}
if app.onMailerAfterUserResetPasswordSend != app.OnMailerAfterUserResetPasswordSend() || app.OnMailerAfterUserResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerAfterUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterUserResetPasswordSend(), app.onMailerAfterUserResetPasswordSend)
if app.onMailerAfterRecordResetPasswordSend != app.OnMailerAfterRecordResetPasswordSend() || app.OnMailerAfterRecordResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordResetPasswordSend(), app.onMailerAfterRecordResetPasswordSend)
}
if app.onMailerBeforeUserVerificationSend != app.OnMailerBeforeUserVerificationSend() || app.OnMailerBeforeUserVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserVerificationSend(), app.onMailerBeforeUserVerificationSend)
if app.onMailerBeforeRecordVerificationSend != app.OnMailerBeforeRecordVerificationSend() || app.OnMailerBeforeRecordVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordVerificationSend(), app.onMailerBeforeRecordVerificationSend)
}
if app.onMailerAfterUserVerificationSend != app.OnMailerAfterUserVerificationSend() || app.OnMailerAfterUserVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerAfterUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterUserVerificationSend(), app.onMailerAfterUserVerificationSend)
if app.onMailerAfterRecordVerificationSend != app.OnMailerAfterRecordVerificationSend() || app.OnMailerAfterRecordVerificationSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordVerificationSend(), app.onMailerAfterRecordVerificationSend)
}
if app.onMailerBeforeUserChangeEmailSend != app.OnMailerBeforeUserChangeEmailSend() || app.OnMailerBeforeUserChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserChangeEmailSend(), app.onMailerBeforeUserChangeEmailSend)
if app.onMailerBeforeRecordChangeEmailSend != app.OnMailerBeforeRecordChangeEmailSend() || app.OnMailerBeforeRecordChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordChangeEmailSend(), app.onMailerBeforeRecordChangeEmailSend)
}
if app.onMailerAfterUserChangeEmailSend != app.OnMailerAfterUserChangeEmailSend() || app.OnMailerAfterUserChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerAfterUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterUserChangeEmailSend(), app.onMailerAfterUserChangeEmailSend)
if app.onMailerAfterRecordChangeEmailSend != app.OnMailerAfterRecordChangeEmailSend() || app.OnMailerAfterRecordChangeEmailSend() == nil {
t.Fatalf("Getter app.OnMailerAfterRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordChangeEmailSend(), app.onMailerAfterRecordChangeEmailSend)
}
if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
@@ -283,48 +283,52 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest)
}
if app.onUsersListRequest != app.OnUsersListRequest() || app.OnUsersListRequest() == nil {
t.Fatalf("Getter app.OnUsersListRequest does not match or nil (%v vs %v)", app.OnUsersListRequest(), app.onUsersListRequest)
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
}
if app.onUserViewRequest != app.OnUserViewRequest() || app.OnUserViewRequest() == nil {
t.Fatalf("Getter app.OnUserViewRequest does not match or nil (%v vs %v)", app.OnUserViewRequest(), app.onUserViewRequest)
if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
}
if app.onUserBeforeCreateRequest != app.OnUserBeforeCreateRequest() || app.OnUserBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnUserBeforeCreateRequest does not match or nil (%v vs %v)", app.OnUserBeforeCreateRequest(), app.onUserBeforeCreateRequest)
if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
}
if app.onUserAfterCreateRequest != app.OnUserAfterCreateRequest() || app.OnUserAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnUserAfterCreateRequest does not match or nil (%v vs %v)", app.OnUserAfterCreateRequest(), app.onUserAfterCreateRequest)
if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
}
if app.onUserBeforeUpdateRequest != app.OnUserBeforeUpdateRequest() || app.OnUserBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnUserBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnUserBeforeUpdateRequest(), app.onUserBeforeUpdateRequest)
if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
}
if app.onUserAfterUpdateRequest != app.OnUserAfterUpdateRequest() || app.OnUserAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnUserAfterUpdateRequest does not match or nil (%v vs %v)", app.OnUserAfterUpdateRequest(), app.onUserAfterUpdateRequest)
if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
}
if app.onUserBeforeDeleteRequest != app.OnUserBeforeDeleteRequest() || app.OnUserBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnUserBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnUserBeforeDeleteRequest(), app.onUserBeforeDeleteRequest)
if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
}
if app.onUserAfterDeleteRequest != app.OnUserAfterDeleteRequest() || app.OnUserAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnUserAfterDeleteRequest does not match or nil (%v vs %v)", app.OnUserAfterDeleteRequest(), app.onUserAfterDeleteRequest)
if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
}
if app.onUserAuthRequest != app.OnUserAuthRequest() || app.OnUserAuthRequest() == nil {
t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest)
if app.onRecordAuthRequest != app.OnRecordAuthRequest() || app.OnRecordAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest)
}
if app.onUserBeforeOauth2Register != app.OnUserBeforeOauth2Register() || app.OnUserBeforeOauth2Register() == nil {
t.Fatalf("Getter app.OnUserBeforeOauth2Register does not match or nil (%v vs %v)", app.OnUserBeforeOauth2Register(), app.onUserBeforeOauth2Register)
if app.onRecordListExternalAuthsRequest != app.OnRecordListExternalAuthsRequest() || app.OnRecordListExternalAuthsRequest() == nil {
t.Fatalf("Getter app.OnRecordListExternalAuthsRequest does not match or nil (%v vs %v)", app.OnRecordListExternalAuthsRequest(), app.onRecordListExternalAuthsRequest)
}
if app.onUserAfterOauth2Register != app.OnUserAfterOauth2Register() || app.OnUserAfterOauth2Register() == nil {
t.Fatalf("Getter app.OnUserAfterOauth2Register does not match or nil (%v vs %v)", app.OnUserAfterOauth2Register(), app.onUserAfterOauth2Register)
if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUnlinkExternalAuthRequest(), app.onRecordBeforeUnlinkExternalAuthRequest)
}
if app.onRecordAfterUnlinkExternalAuthRequest != app.OnRecordAfterUnlinkExternalAuthRequest() || app.OnRecordAfterUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnRecordAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordAfterUnlinkExternalAuthRequest(), app.onRecordAfterUnlinkExternalAuthRequest)
}
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
+13 -2
View File
@@ -4,19 +4,30 @@ package core
import (
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
_ "github.com/mattn/go-sqlite3"
)
func connectDB(dbPath string) (*dbx.DB, error) {
pragmas := "_foreign_keys=1&_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=8000"
// 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 := "_busy_timeout=10000&_journal_mode=WAL&_foreign_keys=1&_synchronous=NORMAL"
db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas))
if openErr != nil {
return nil, openErr
}
// use a fixed connection pool to limit the SQLITE_BUSY errors
// and reduce the open file descriptors
// (the limits are arbitrary and may change in the future)
db.DB().SetMaxOpenConns(800)
db.DB().SetMaxIdleConns(30)
db.DB().SetConnMaxIdleTime(5 * time.Minute)
// additional pragmas not supported through the dsn string
_, err := db.NewQuery(`
pragma journal_size_limit = 100000000;
+20 -2
View File
@@ -4,13 +4,31 @@ package core
import (
"fmt"
"time"
"github.com/pocketbase/dbx"
_ "modernc.org/sqlite"
)
func connectDB(dbPath string) (*dbx.DB, error) {
pragmas := "_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(8000)&_pragma=journal_size_limit(100000000)"
// 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=foreign_keys(1)&_pragma=synchronous(NORMAL)&_pragma=journal_size_limit(100000000)"
return dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas))
db, err := dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas))
if err != nil {
return nil, err
}
// use a fixed connection pool to limit the SQLITE_BUSY errors and
// reduce the open file descriptors
// (the limits are arbitrary and may change in the future)
//
// @see https://gitlab.com/cznic/sqlite/-/issues/115
db.DB().SetMaxOpenConns(800)
db.DB().SetMaxIdleConns(30)
db.DB().SetConnMaxIdleTime(5 * time.Minute)
return db, nil
}
+87 -50
View File
@@ -4,7 +4,7 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
@@ -16,11 +16,20 @@ import (
// Serve events data
// -------------------------------------------------------------------
type BootstrapEvent struct {
App App
}
type ServeEvent struct {
App App
Router *echo.Echo
}
type ApiErrorEvent struct {
HttpContext echo.Context
Error error
}
// -------------------------------------------------------------------
// Model DAO events data
// -------------------------------------------------------------------
@@ -34,14 +43,16 @@ type ModelEvent struct {
// Mailer events data
// -------------------------------------------------------------------
type MailerUserEvent struct {
type MailerRecordEvent struct {
MailClient mailer.Mailer
User *models.User
Message *mailer.Message
Record *models.Record
Meta map[string]any
}
type MailerAdminEvent struct {
MailClient mailer.Mailer
Message *mailer.Message
Admin *models.Admin
Meta map[string]any
}
@@ -55,6 +66,17 @@ type RealtimeConnectEvent struct {
Client subscriptions.Client
}
type RealtimeDisconnectEvent struct {
HttpContext echo.Context
Client subscriptions.Client
}
type RealtimeMessageEvent struct {
HttpContext echo.Context
Client subscriptions.Client
Message *subscriptions.Message
}
type RealtimeSubscribeEvent struct {
HttpContext echo.Context
Client subscriptions.Client
@@ -67,17 +89,17 @@ type RealtimeSubscribeEvent struct {
type SettingsListEvent struct {
HttpContext echo.Context
RedactedSettings *Settings
RedactedSettings *settings.Settings
}
type SettingsUpdateEvent struct {
HttpContext echo.Context
OldSettings *Settings
NewSettings *Settings
OldSettings *settings.Settings
NewSettings *settings.Settings
}
// -------------------------------------------------------------------
// Record API events data
// Record CRUD API events data
// -------------------------------------------------------------------
type RecordsListEvent struct {
@@ -107,6 +129,59 @@ type RecordDeleteEvent struct {
Record *models.Record
}
// -------------------------------------------------------------------
// Auth Record API events data
// -------------------------------------------------------------------
type RecordAuthEvent struct {
HttpContext echo.Context
Record *models.Record
Token string
Meta any
}
type RecordRequestPasswordResetEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmPasswordResetEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordRequestVerificationEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmVerificationEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordRequestEmailChangeEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmEmailChangeEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordListExternalAuthsEvent struct {
HttpContext echo.Context
Record *models.Record
ExternalAuths []*models.ExternalAuth
}
type RecordUnlinkExternalAuthEvent struct {
HttpContext echo.Context
Record *models.Record
ExternalAuth *models.ExternalAuth
}
// -------------------------------------------------------------------
// Admin API events data
// -------------------------------------------------------------------
@@ -143,49 +218,6 @@ type AdminAuthEvent struct {
Token string
}
// -------------------------------------------------------------------
// User API events data
// -------------------------------------------------------------------
type UsersListEvent struct {
HttpContext echo.Context
Users []*models.User
Result *search.Result
}
type UserViewEvent struct {
HttpContext echo.Context
User *models.User
}
type UserCreateEvent struct {
HttpContext echo.Context
User *models.User
}
type UserUpdateEvent struct {
HttpContext echo.Context
User *models.User
}
type UserDeleteEvent struct {
HttpContext echo.Context
User *models.User
}
type UserAuthEvent struct {
HttpContext echo.Context
User *models.User
Token string
Meta any
}
type UserOauth2RegisterEvent struct {
HttpContext echo.Context
User *models.User
AuthData *auth.AuthUser
}
// -------------------------------------------------------------------
// Collection API events data
// -------------------------------------------------------------------
@@ -216,6 +248,11 @@ type CollectionDeleteEvent struct {
Collection *models.Collection
}
type CollectionsImportEvent struct {
HttpContext echo.Context
Collections []*models.Collection
}
// -------------------------------------------------------------------
// File API events data
// -------------------------------------------------------------------
-412
View File
@@ -1,412 +0,0 @@
package core
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/security"
)
// Common settings placeholder tokens
const (
EmailPlaceholderAppUrl string = "%APP_URL%"
EmailPlaceholderToken string = "%TOKEN%"
)
// Settings defines common app configuration options.
type Settings struct {
mux sync.RWMutex
Meta MetaConfig `form:"meta" json:"meta"`
Logs LogsConfig `form:"logs" json:"logs"`
Smtp SmtpConfig `form:"smtp" json:"smtp"`
S3 S3Config `form:"s3" json:"s3"`
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
UserAuthToken TokenConfig `form:"userAuthToken" json:"userAuthToken"`
UserPasswordResetToken TokenConfig `form:"userPasswordResetToken" json:"userPasswordResetToken"`
UserEmailChangeToken TokenConfig `form:"userEmailChangeToken" json:"userEmailChangeToken"`
UserVerificationToken TokenConfig `form:"userVerificationToken" json:"userVerificationToken"`
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
GitlabAuth AuthProviderConfig `form:"gitlabAuth" json:"gitlabAuth"`
}
// NewSettings creates and returns a new default Settings instance.
func NewSettings() *Settings {
return &Settings{
Meta: MetaConfig{
AppName: "Acme",
AppUrl: "http://localhost:8090",
SenderName: "Support",
SenderAddress: "support@example.com",
UserVerificationUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-verification/" + EmailPlaceholderToken,
UserResetPasswordUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-password-reset/" + EmailPlaceholderToken,
UserConfirmEmailChangeUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-email-change/" + EmailPlaceholderToken,
},
Logs: LogsConfig{
MaxDays: 7,
},
Smtp: SmtpConfig{
Enabled: false,
Host: "smtp.example.com",
Port: 587,
Username: "",
Password: "",
Tls: false,
},
AdminAuthToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1209600, // 14 days,
},
AdminPasswordResetToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
UserAuthToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1209600, // 14 days,
},
UserPasswordResetToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
UserVerificationToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 604800, // 7 days,
},
UserEmailChangeToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
EmailAuth: EmailAuthConfig{
Enabled: true,
MinPasswordLength: 8,
},
GoogleAuth: AuthProviderConfig{
Enabled: false,
AllowRegistrations: true,
},
FacebookAuth: AuthProviderConfig{
Enabled: false,
AllowRegistrations: true,
},
GithubAuth: AuthProviderConfig{
Enabled: false,
AllowRegistrations: true,
},
GitlabAuth: AuthProviderConfig{
Enabled: false,
AllowRegistrations: true,
},
}
}
// Validate makes Settings validatable by implementing [validation.Validatable] interface.
func (s *Settings) Validate() error {
s.mux.Lock()
defer s.mux.Unlock()
return validation.ValidateStruct(s,
validation.Field(&s.Meta),
validation.Field(&s.Logs),
validation.Field(&s.AdminAuthToken),
validation.Field(&s.AdminPasswordResetToken),
validation.Field(&s.UserAuthToken),
validation.Field(&s.UserPasswordResetToken),
validation.Field(&s.UserEmailChangeToken),
validation.Field(&s.UserVerificationToken),
validation.Field(&s.Smtp),
validation.Field(&s.S3),
validation.Field(&s.EmailAuth),
validation.Field(&s.GoogleAuth),
validation.Field(&s.FacebookAuth),
validation.Field(&s.GithubAuth),
validation.Field(&s.GitlabAuth),
)
}
// Merge merges `other` settings into the current one.
func (s *Settings) Merge(other *Settings) error {
s.mux.Lock()
defer s.mux.Unlock()
bytes, err := json.Marshal(other)
if err != nil {
return err
}
return json.Unmarshal(bytes, s)
}
// Clone creates a new deep copy of the current settings.
func (s *Settings) Clone() (*Settings, error) {
settings := &Settings{}
if err := settings.Merge(s); err != nil {
return nil, err
}
return settings, nil
}
// RedactClone creates a new deep copy of the current settings,
// while replacing the secret values with `******`.
func (s *Settings) RedactClone() (*Settings, error) {
clone, err := s.Clone()
if err != nil {
return nil, err
}
mask := "******"
sensitiveFields := []*string{
&clone.Smtp.Password,
&clone.S3.Secret,
&clone.AdminAuthToken.Secret,
&clone.AdminPasswordResetToken.Secret,
&clone.UserAuthToken.Secret,
&clone.UserPasswordResetToken.Secret,
&clone.UserEmailChangeToken.Secret,
&clone.UserVerificationToken.Secret,
&clone.GoogleAuth.ClientSecret,
&clone.FacebookAuth.ClientSecret,
&clone.GithubAuth.ClientSecret,
&clone.GitlabAuth.ClientSecret,
}
// mask all sensitive fields
for _, v := range sensitiveFields {
if v != nil && *v != "" {
*v = mask
}
}
return clone, nil
}
// NamedAuthProviderConfigs returns a map with all registered OAuth2
// provider configurations (indexed by their name identifier).
func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
return map[string]AuthProviderConfig{
auth.NameGoogle: s.GoogleAuth,
auth.NameFacebook: s.FacebookAuth,
auth.NameGithub: s.GithubAuth,
auth.NameGitlab: s.GitlabAuth,
}
}
// -------------------------------------------------------------------
type TokenConfig struct {
Secret string `form:"secret" json:"secret"`
Duration int64 `form:"duration" json:"duration"`
}
// Validate makes TokenConfig validatable by implementing [validation.Validatable] interface.
func (c TokenConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Secret, validation.Required, validation.Length(30, 300)),
validation.Field(&c.Duration, validation.Required, validation.Min(5), validation.Max(31536000)),
)
}
// -------------------------------------------------------------------
type SmtpConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
Host string `form:"host" json:"host"`
Port int `form:"port" json:"port"`
Username string `form:"username" json:"username"`
Password string `form:"password" json:"password"`
// Whether to enforce TLS encryption for the mail server connection.
//
// When set to false StartTLS command is send, leaving the server
// to decide whether to upgrade the connection or not.
Tls bool `form:"tls" json:"tls"`
}
// Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface.
func (c SmtpConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Host, is.Host, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.Port, validation.When(c.Enabled, validation.Required), validation.Min(0)),
)
}
// -------------------------------------------------------------------
type S3Config struct {
Enabled bool `form:"enabled" json:"enabled"`
Bucket string `form:"bucket" json:"bucket"`
Region string `form:"region" json:"region"`
Endpoint string `form:"endpoint" json:"endpoint"`
AccessKey string `form:"accessKey" json:"accessKey"`
Secret string `form:"secret" json:"secret"`
}
// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
func (c S3Config) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Endpoint, is.Host, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.Bucket, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.Region, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.AccessKey, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.Secret, validation.When(c.Enabled, validation.Required)),
)
}
// -------------------------------------------------------------------
type MetaConfig struct {
AppName string `form:"appName" json:"appName"`
AppUrl string `form:"appUrl" json:"appUrl"`
SenderName string `form:"senderName" json:"senderName"`
SenderAddress string `form:"senderAddress" json:"senderAddress"`
UserVerificationUrl string `form:"userVerificationUrl" json:"userVerificationUrl"`
UserResetPasswordUrl string `form:"userResetPasswordUrl" json:"userResetPasswordUrl"`
UserConfirmEmailChangeUrl string `form:"userConfirmEmailChangeUrl" json:"userConfirmEmailChangeUrl"`
}
// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
func (c MetaConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
validation.Field(&c.AppUrl, validation.Required, is.URL),
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
validation.Field(&c.SenderAddress, is.Email, validation.Required),
validation.Field(
&c.UserVerificationUrl,
validation.Required,
validation.By(c.checkPlaceholders(EmailPlaceholderToken)),
),
validation.Field(
&c.UserResetPasswordUrl,
validation.Required,
validation.By(c.checkPlaceholders(EmailPlaceholderToken)),
),
validation.Field(
&c.UserConfirmEmailChangeUrl,
validation.Required,
validation.By(c.checkPlaceholders(EmailPlaceholderToken)),
),
)
}
func (c *MetaConfig) checkPlaceholders(params ...string) validation.RuleFunc {
return func(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
for _, param := range params {
if !strings.Contains(v, param) {
return validation.NewError("validation_missing_required_param", fmt.Sprintf("Missing required parameter %q", param))
}
}
return nil
}
}
// -------------------------------------------------------------------
type LogsConfig struct {
MaxDays int `form:"maxDays" json:"maxDays"`
}
// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface.
func (c LogsConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.MaxDays, validation.Min(0)),
)
}
// -------------------------------------------------------------------
type AuthProviderConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
AllowRegistrations bool `form:"allowRegistrations" json:"allowRegistrations"`
ClientId string `form:"clientId" json:"clientId,omitempty"`
ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
}
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
func (c AuthProviderConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.ClientId, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.ClientSecret, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.AuthUrl, is.URL),
validation.Field(&c.TokenUrl, is.URL),
validation.Field(&c.UserApiUrl, is.URL),
)
}
// SetupProvider loads the current AuthProviderConfig into the specified provider.
func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
if !c.Enabled {
return errors.New("The provider is not enabled.")
}
if c.ClientId != "" {
provider.SetClientId(c.ClientId)
}
if c.ClientSecret != "" {
provider.SetClientSecret(c.ClientSecret)
}
if c.AuthUrl != "" {
provider.SetAuthUrl(c.AuthUrl)
}
if c.UserApiUrl != "" {
provider.SetUserApiUrl(c.UserApiUrl)
}
if c.TokenUrl != "" {
provider.SetTokenUrl(c.TokenUrl)
}
return nil
}
// -------------------------------------------------------------------
type EmailAuthConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
}
// Validate makes `EmailAuthConfig` validatable by implementing [validation.Validatable] interface.
func (c EmailAuthConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(
&c.ExceptDomains,
validation.When(len(c.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
validation.Field(
&c.OnlyDomains,
validation.When(len(c.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
validation.Field(
&c.MinPasswordLength,
validation.When(c.Enabled, validation.Required),
validation.Min(5),
validation.Max(100),
),
)
}
-606
View File
@@ -1,606 +0,0 @@
package core_test
import (
"encoding/json"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/auth"
)
func TestSettingsValidate(t *testing.T) {
s := core.NewSettings()
// set invalid settings data
s.Meta.AppName = ""
s.Logs.MaxDays = -10
s.Smtp.Enabled = true
s.Smtp.Host = ""
s.S3.Enabled = true
s.S3.Endpoint = "invalid"
s.AdminAuthToken.Duration = -10
s.AdminPasswordResetToken.Duration = -10
s.UserAuthToken.Duration = -10
s.UserPasswordResetToken.Duration = -10
s.UserEmailChangeToken.Duration = -10
s.UserVerificationToken.Duration = -10
s.EmailAuth.Enabled = true
s.EmailAuth.MinPasswordLength = -10
s.GoogleAuth.Enabled = true
s.GoogleAuth.ClientId = ""
s.FacebookAuth.Enabled = true
s.FacebookAuth.ClientId = ""
s.GithubAuth.Enabled = true
s.GithubAuth.ClientId = ""
s.GitlabAuth.Enabled = true
s.GitlabAuth.ClientId = ""
// check if Validate() is triggering the members validate methods.
err := s.Validate()
if err == nil {
t.Fatalf("Expected error, got nil")
}
expectations := []string{
`"meta":{`,
`"logs":{`,
`"smtp":{`,
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"userAuthToken":{`,
`"userPasswordResetToken":{`,
`"userEmailChangeToken":{`,
`"userVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
}
errBytes, _ := json.Marshal(err)
jsonErr := string(errBytes)
for _, expected := range expectations {
if !strings.Contains(jsonErr, expected) {
t.Errorf("Expected error key %s in %v", expected, jsonErr)
}
}
}
func TestSettingsMerge(t *testing.T) {
s1 := core.NewSettings()
s1.Meta.AppUrl = "old_app_url"
s2 := core.NewSettings()
s2.Meta.AppName = "test"
s2.Logs.MaxDays = 123
s2.Smtp.Host = "test"
s2.Smtp.Enabled = true
s2.S3.Enabled = true
s2.S3.Endpoint = "test"
s2.AdminAuthToken.Duration = 1
s2.AdminPasswordResetToken.Duration = 2
s2.UserAuthToken.Duration = 3
s2.UserPasswordResetToken.Duration = 4
s2.UserEmailChangeToken.Duration = 5
s2.UserVerificationToken.Duration = 6
s2.EmailAuth.Enabled = false
s2.EmailAuth.MinPasswordLength = 30
s2.GoogleAuth.Enabled = true
s2.GoogleAuth.ClientId = "google_test"
s2.FacebookAuth.Enabled = true
s2.FacebookAuth.ClientId = "facebook_test"
s2.GithubAuth.Enabled = true
s2.GithubAuth.ClientId = "github_test"
s2.GitlabAuth.Enabled = true
s2.GitlabAuth.ClientId = "gitlab_test"
if err := s1.Merge(s2); err != nil {
t.Fatal(err)
}
s1Encoded, err := json.Marshal(s1)
if err != nil {
t.Fatal(err)
}
s2Encoded, err := json.Marshal(s2)
if err != nil {
t.Fatal(err)
}
if string(s1Encoded) != string(s2Encoded) {
t.Fatalf("Expected the same serialization, got %v VS %v", string(s1Encoded), string(s2Encoded))
}
}
func TestSettingsClone(t *testing.T) {
s1 := core.NewSettings()
s2, err := s1.Clone()
if err != nil {
t.Fatal(err)
}
s1Bytes, err := json.Marshal(s1)
if err != nil {
t.Fatal(err)
}
s2Bytes, err := json.Marshal(s2)
if err != nil {
t.Fatal(err)
}
if string(s1Bytes) != string(s2Bytes) {
t.Fatalf("Expected equivalent serialization, got %v VS %v", string(s1Bytes), string(s2Bytes))
}
// verify that it is a deep copy
s1.Meta.AppName = "new"
if s1.Meta.AppName == s2.Meta.AppName {
t.Fatalf("Expected s1 and s2 to have different Meta.AppName, got %s", s1.Meta.AppName)
}
}
func TestSettingsRedactClone(t *testing.T) {
s1 := core.NewSettings()
s1.Meta.AppName = "test123" // control field
s1.Smtp.Password = "test123"
s1.Smtp.Tls = true
s1.S3.Secret = "test123"
s1.AdminAuthToken.Secret = "test123"
s1.AdminPasswordResetToken.Secret = "test123"
s1.UserAuthToken.Secret = "test123"
s1.UserPasswordResetToken.Secret = "test123"
s1.UserEmailChangeToken.Secret = "test123"
s1.UserVerificationToken.Secret = "test123"
s1.GoogleAuth.ClientSecret = "test123"
s1.FacebookAuth.ClientSecret = "test123"
s1.GithubAuth.ClientSecret = "test123"
s1.GitlabAuth.ClientSecret = "test123"
s2, err := s1.RedactClone()
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(s2)
if err != nil {
t.Fatal(err)
}
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","senderName":"Support","senderAddress":"support@example.com","userVerificationUrl":"%APP_URL%/_/#/users/confirm-verification/%TOKEN%","userResetPasswordUrl":"%APP_URL%/_/#/users/confirm-password-reset/%TOKEN%","userConfirmEmailChangeUrl":"%APP_URL%/_/#/users/confirm-email-change/%TOKEN%"},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******"},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
if encodedStr := string(encoded); encodedStr != expected {
t.Fatalf("Expected %v, got \n%v", expected, encodedStr)
}
}
func TestNamedAuthProviderConfigs(t *testing.T) {
s := core.NewSettings()
s.GoogleAuth.ClientId = "google_test"
s.FacebookAuth.ClientId = "facebook_test"
s.GithubAuth.ClientId = "github_test"
s.GitlabAuth.ClientId = "gitlab_test"
s.GitlabAuth.Enabled = true
result := s.NamedAuthProviderConfigs()
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
expected := `{"facebook":{"enabled":false,"allowRegistrations":true,"clientId":"facebook_test"},"github":{"enabled":false,"allowRegistrations":true,"clientId":"github_test"},"gitlab":{"enabled":true,"allowRegistrations":true,"clientId":"gitlab_test"},"google":{"enabled":false,"allowRegistrations":true,"clientId":"google_test"}}`
if encodedStr := string(encoded); encodedStr != expected {
t.Fatalf("Expected the same serialization, got %v", encodedStr)
}
}
func TestTokenConfigValidate(t *testing.T) {
scenarios := []struct {
config core.TokenConfig
expectError bool
}{
// zero values
{
core.TokenConfig{},
true,
},
// invalid data
{
core.TokenConfig{
Secret: "test",
Duration: 4,
},
true,
},
// valid data
{
core.TokenConfig{
Secret: "testtesttesttesttesttesttestte",
Duration: 100,
},
false,
},
}
for i, scenario := range scenarios {
result := scenario.config.Validate()
if result != nil && !scenario.expectError {
t.Errorf("(%d) Didn't expect error, got %v", i, result)
}
if result == nil && scenario.expectError {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
func TestSmtpConfigValidate(t *testing.T) {
scenarios := []struct {
config core.SmtpConfig
expectError bool
}{
// zero values (disabled)
{
core.SmtpConfig{},
false,
},
// zero values (enabled)
{
core.SmtpConfig{Enabled: true},
true,
},
// invalid data
{
core.SmtpConfig{
Enabled: true,
Host: "test:test:test",
Port: -10,
},
true,
},
// valid data
{
core.SmtpConfig{
Enabled: true,
Host: "example.com",
Port: 100,
Tls: true,
},
false,
},
}
for i, scenario := range scenarios {
result := scenario.config.Validate()
if result != nil && !scenario.expectError {
t.Errorf("(%d) Didn't expect error, got %v", i, result)
}
if result == nil && scenario.expectError {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
func TestS3ConfigValidate(t *testing.T) {
scenarios := []struct {
config core.S3Config
expectError bool
}{
// zero values (disabled)
{
core.S3Config{},
false,
},
// zero values (enabled)
{
core.S3Config{Enabled: true},
true,
},
// invalid data
{
core.S3Config{
Enabled: true,
Endpoint: "test:test:test",
},
true,
},
// valid data
{
core.S3Config{
Enabled: true,
Endpoint: "example.com",
Bucket: "test",
Region: "test",
AccessKey: "test",
Secret: "test",
},
false,
},
}
for i, scenario := range scenarios {
result := scenario.config.Validate()
if result != nil && !scenario.expectError {
t.Errorf("(%d) Didn't expect error, got %v", i, result)
}
if result == nil && scenario.expectError {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
func TestMetaConfigValidate(t *testing.T) {
scenarios := []struct {
config core.MetaConfig
expectError bool
}{
// zero values
{
core.MetaConfig{},
true,
},
// invalid data
{
core.MetaConfig{
AppName: strings.Repeat("a", 300),
AppUrl: "test",
SenderName: strings.Repeat("a", 300),
SenderAddress: "invalid_email",
UserVerificationUrl: "test",
UserResetPasswordUrl: "test",
UserConfirmEmailChangeUrl: "test",
},
true,
},
// invalid data (missing required placeholders)
{
core.MetaConfig{
AppName: "test",
AppUrl: "https://example.com",
SenderName: "test",
SenderAddress: "test@example.com",
UserVerificationUrl: "https://example.com",
UserResetPasswordUrl: "https://example.com",
UserConfirmEmailChangeUrl: "https://example.com",
},
true,
},
// valid data
{
core.MetaConfig{
AppName: "test",
AppUrl: "https://example.com",
SenderName: "test",
SenderAddress: "test@example.com",
UserVerificationUrl: "https://example.com/" + core.EmailPlaceholderToken,
UserResetPasswordUrl: "https://example.com/" + core.EmailPlaceholderToken,
UserConfirmEmailChangeUrl: "https://example.com/" + core.EmailPlaceholderToken,
},
false,
},
}
for i, scenario := range scenarios {
result := scenario.config.Validate()
if result != nil && !scenario.expectError {
t.Errorf("(%d) Didn't expect error, got %v", i, result)
}
if result == nil && scenario.expectError {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
func TestLogsConfigValidate(t *testing.T) {
scenarios := []struct {
config core.LogsConfig
expectError bool
}{
// zero values
{
core.LogsConfig{},
false,
},
// invalid data
{
core.LogsConfig{MaxDays: -10},
true,
},
// valid data
{
core.LogsConfig{MaxDays: 1},
false,
},
}
for i, scenario := range scenarios {
result := scenario.config.Validate()
if result != nil && !scenario.expectError {
t.Errorf("(%d) Didn't expect error, got %v", i, result)
}
if result == nil && scenario.expectError {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
func TestAuthProviderConfigValidate(t *testing.T) {
scenarios := []struct {
config core.AuthProviderConfig
expectError bool
}{
// zero values (disabled)
{
core.AuthProviderConfig{},
false,
},
// zero values (enabled)
{
core.AuthProviderConfig{Enabled: true},
true,
},
// invalid data
{
core.AuthProviderConfig{
Enabled: true,
ClientId: "",
ClientSecret: "",
AuthUrl: "test",
TokenUrl: "test",
UserApiUrl: "test",
},
true,
},
// valid data (only the required)
{
core.AuthProviderConfig{
Enabled: true,
ClientId: "test",
ClientSecret: "test",
},
false,
},
// valid data (fill all fields)
{
core.AuthProviderConfig{
Enabled: true,
ClientId: "test",
ClientSecret: "test",
AuthUrl: "https://example.com",
TokenUrl: "https://example.com",
UserApiUrl: "https://example.com",
},
false,
},
}
for i, scenario := range scenarios {
result := scenario.config.Validate()
if result != nil && !scenario.expectError {
t.Errorf("(%d) Didn't expect error, got %v", i, result)
}
if result == nil && scenario.expectError {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
func TestAuthProviderConfigSetupProvider(t *testing.T) {
provider := auth.NewGithubProvider()
// disabled config
c1 := core.AuthProviderConfig{Enabled: false}
if err := c1.SetupProvider(provider); err == nil {
t.Errorf("Expected error, got nil")
}
c2 := core.AuthProviderConfig{
Enabled: true,
ClientId: "test_ClientId",
ClientSecret: "test_ClientSecret",
AuthUrl: "test_AuthUrl",
UserApiUrl: "test_UserApiUrl",
TokenUrl: "test_TokenUrl",
}
if err := c2.SetupProvider(provider); err != nil {
t.Error(err)
}
encoded, _ := json.Marshal(c2)
expected := `{"enabled":true,"allowRegistrations":false,"clientId":"test_ClientId","clientSecret":"test_ClientSecret","authUrl":"test_AuthUrl","tokenUrl":"test_TokenUrl","userApiUrl":"test_UserApiUrl"}`
if string(encoded) != expected {
t.Errorf("Expected %s, got %s", expected, string(encoded))
}
}
func TestEmailAuthConfigValidate(t *testing.T) {
scenarios := []struct {
config core.EmailAuthConfig
expectError bool
}{
// zero values (disabled)
{
core.EmailAuthConfig{},
false,
},
// zero values (enabled)
{
core.EmailAuthConfig{Enabled: true},
true,
},
// invalid data (only the required)
{
core.EmailAuthConfig{
Enabled: true,
MinPasswordLength: 4,
},
true,
},
// valid data (only the required)
{
core.EmailAuthConfig{
Enabled: true,
MinPasswordLength: 5,
},
false,
},
// invalid data (both OnlyDomains and ExceptDomains set)
{
core.EmailAuthConfig{
Enabled: true,
MinPasswordLength: 5,
OnlyDomains: []string{"example.com", "test.com"},
ExceptDomains: []string{"example.com", "test.com"},
},
true,
},
// valid data (only onlyDomains set)
{
core.EmailAuthConfig{
Enabled: true,
MinPasswordLength: 5,
OnlyDomains: []string{"example.com", "test.com"},
},
false,
},
// valid data (only exceptDomains set)
{
core.EmailAuthConfig{
Enabled: true,
MinPasswordLength: 5,
ExceptDomains: []string{"example.com", "test.com"},
},
false,
},
}
for i, scenario := range scenarios {
result := scenario.config.Validate()
if result != nil && !scenario.expectError {
t.Errorf("(%d) Didn't expect error, got %v", i, result)
}
if result == nil && scenario.expectError {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -49,6 +50,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
//
// Returns an error if the JWT token is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
// @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
@@ -86,20 +88,22 @@ func (dao *Dao) TotalAdmins() (int, error) {
// IsAdminEmailUnique checks if the provided email address is not
// already in use by other admins.
func (dao *Dao) IsAdminEmailUnique(email string, excludeId string) bool {
func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
if email == "" {
return false
}
var exists bool
err := dao.AdminQuery().
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
query := dao.AdminQuery().Select("count(*)").
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
Row(&exists)
Limit(1)
return err == nil && !exists
if len(excludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
}
var exists bool
return query.Row(&exists) == nil && !exists
}
// DeleteAdmin deletes the provided Admin model.
+32 -12
View File
@@ -27,8 +27,9 @@ func TestFindAdminById(t *testing.T) {
id string
expectError bool
}{
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
{"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false},
{" ", true},
{"missing", true},
{"9q2trqumvlyr3bd", false},
}
for i, scenario := range scenarios {
@@ -53,6 +54,7 @@ func TestFindAdminByEmail(t *testing.T) {
email string
expectError bool
}{
{"", true},
{"invalid", true},
{"missing@example.com", true},
{"test@example.com", false},
@@ -83,23 +85,30 @@ func TestFindAdminByToken(t *testing.T) {
expectedEmail string
expectError bool
}{
// invalid base key (password reset key for auth token)
// invalid auth token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
app.Settings().AdminPasswordResetToken.Secret,
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
app.Settings().AdminAuthToken.Secret,
"",
true,
},
// expired token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
app.Settings().AdminAuthToken.Secret,
"",
true,
},
// wrong base token (password reset token secret instead of auth secret)
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
app.Settings().AdminPasswordResetToken.Secret,
"",
true,
},
// valid token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
app.Settings().AdminAuthToken.Secret,
"test@example.com",
false,
@@ -129,8 +138,8 @@ func TestTotalAdmins(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result1 != 2 {
t.Fatalf("Expected 2 admins, got %d", result1)
if result1 != 3 {
t.Fatalf("Expected 3 admins, got %d", result1)
}
// delete all
@@ -156,8 +165,10 @@ func TestIsAdminEmailUnique(t *testing.T) {
}{
{"", "", false},
{"test@example.com", "", false},
{"test2@example.com", "", false},
{"test3@example.com", "", false},
{"new@example.com", "", true},
{"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true},
{"test@example.com", "sywbhecnh46rhm0", true},
}
for i, scenario := range scenarios {
@@ -186,15 +197,24 @@ func TestDeleteAdmin(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin3, err := app.Dao().FindAdminByEmail("test3@example.com")
if err != nil {
t.Fatal(err)
}
deleteErr1 := app.Dao().DeleteAdmin(admin1)
if deleteErr1 != nil {
t.Fatal(deleteErr1)
}
// cannot delete the only remaining admin
deleteErr2 := app.Dao().DeleteAdmin(admin2)
if deleteErr2 == nil {
if deleteErr2 != nil {
t.Fatal(deleteErr2)
}
// cannot delete the only remaining admin
deleteErr3 := app.Dao().DeleteAdmin(admin3)
if deleteErr3 == nil {
t.Fatal("Expected delete error, got nil")
}
+174 -46
View File
@@ -4,13 +4,19 @@
package daos
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"golang.org/x/sync/semaphore"
)
const DefaultMaxFailRetries = 5
// New creates a new Dao instance with the provided db builder.
func New(db dbx.Builder) *Dao {
return &Dao{
@@ -21,7 +27,9 @@ func New(db dbx.Builder) *Dao {
// Dao handles various db operations.
// Think of Dao as a repository and service layer in one.
type Dao struct {
db dbx.Builder
db dbx.Builder
sem *semaphore.Weighted
mux sync.RWMutex
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
AfterCreateFunc func(eventDao *Dao, m models.Model)
@@ -36,11 +44,51 @@ func (dao *Dao) DB() dbx.Builder {
return dao.db
}
// Block acquires a lock and blocks all other go routines that uses
// the Dao instance until dao.Continue() is called, effectively making
// the concurrent requests to perform synchronous db operations.
//
// This method should be used only as a last resort and as a workaround
// for the SQLITE_BUSY error when mixing read&write in a transaction.
//
// Example:
//
// func someLongRunningTransaction() error {
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer cancel()
// if err := app.Dao().Block(ctx); err != nil {
// return err
// }
// defer app.Dao().Continue()
//
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
// // some long running read&write transaction...
// })
// }
func (dao *Dao) Block(ctx context.Context) error {
if dao.sem == nil {
dao.mux.Lock()
dao.sem = semaphore.NewWeighted(1)
dao.mux.Unlock()
}
return dao.sem.Acquire(ctx, 1)
}
// Continue releases the previously acquired Block() lock.
func (dao *Dao) Continue() {
if dao.sem == nil {
return
}
dao.sem.Release(1)
}
// ModelQuery creates a new query with preset Select and From fields
// based on the provided model argument.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
tableName := m.TableName()
return dao.db.Select(fmt.Sprintf("{{%s}}.*", tableName)).From(tableName)
return dao.db.Select("{{" + tableName + "}}.*").From(tableName)
}
// FindById finds a single db record with the specified id and
@@ -49,6 +97,12 @@ func (dao *Dao) FindById(m models.Model, id string) error {
return dao.ModelQuery(m).Where(dbx.HashExp{"id": id}).Limit(1).One(m)
}
type afterCallGroup struct {
Action string
EventDao *Dao
Model models.Model
}
// RunInTransaction wraps fn into a transaction.
//
// It is safe to nest RunInTransaction calls.
@@ -57,47 +111,73 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
case *dbx.Tx:
// nested transactions are not supported by default
// so execute the function within the current transaction
return fn(dao)
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
txDao := New(txOrDB)
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
txDao.AfterCreateFunc = dao.AfterCreateFunc
txDao.AfterUpdateFunc = dao.AfterUpdateFunc
txDao.AfterDeleteFunc = dao.AfterDeleteFunc
return fn(txDao)
case *dbx.DB:
return txOrDB.Transactional(func(tx *dbx.Tx) error {
afterCalls := []afterCallGroup{}
txError := txOrDB.Transactional(func(tx *dbx.Tx) error {
txDao := New(tx)
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeCreateFunc != nil {
if dao.BeforeCreateFunc != nil {
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeCreateFunc(eventDao, m)
}
return nil
}
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(eventDao, m)
}
}
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeUpdateFunc != nil {
if dao.BeforeUpdateFunc != nil {
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeUpdateFunc(eventDao, m)
}
return nil
}
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterUpdateFunc != nil {
dao.AfterUpdateFunc(eventDao, m)
}
}
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeDeleteFunc != nil {
if dao.BeforeDeleteFunc != nil {
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeDeleteFunc(eventDao, m)
}
return nil
}
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterDeleteFunc != nil {
dao.AfterDeleteFunc(eventDao, m)
if dao.AfterCreateFunc != nil {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
}
}
if dao.AfterUpdateFunc != nil {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
}
}
if dao.AfterDeleteFunc != nil {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
}
}
return fn(txDao)
})
if txError == nil {
// execute after event calls on successful transaction
for _, call := range afterCalls {
switch call.Action {
case "create":
dao.AfterCreateFunc(call.EventDao, call.Model)
case "update":
dao.AfterUpdateFunc(call.EventDao, call.Model)
case "delete":
dao.AfterDeleteFunc(call.EventDao, call.Model)
}
}
}
return txError
}
return errors.New("Failed to start transaction (unknown dao.db)")
@@ -109,31 +189,36 @@ func (dao *Dao) Delete(m models.Model) error {
return errors.New("ID is not set")
}
if dao.BeforeDeleteFunc != nil {
if err := dao.BeforeDeleteFunc(dao, m); err != nil {
return dao.failRetry(func(retryDao *Dao) error {
if retryDao.BeforeDeleteFunc != nil {
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
return err
}
}
if err := retryDao.db.Model(m).Delete(); err != nil {
return err
}
}
deleteErr := dao.db.Model(m).Delete()
if deleteErr != nil {
return deleteErr
}
if retryDao.AfterDeleteFunc != nil {
retryDao.AfterDeleteFunc(retryDao, m)
}
if dao.AfterDeleteFunc != nil {
dao.AfterDeleteFunc(dao, m)
}
return nil
return nil
}, DefaultMaxFailRetries)
}
// Save upserts (update or create if primary key is not set) the provided model.
func (dao *Dao) Save(m models.Model) error {
if m.HasId() {
return dao.update(m)
if m.IsNew() {
return dao.failRetry(func(retryDao *Dao) error {
return retryDao.create(m)
}, DefaultMaxFailRetries)
}
return dao.create(m)
return dao.failRetry(func(retryDao *Dao) error {
return retryDao.update(m)
}, DefaultMaxFailRetries)
}
func (dao *Dao) update(m models.Model) error {
@@ -141,6 +226,10 @@ func (dao *Dao) update(m models.Model) error {
return errors.New("ID is not set")
}
if m.GetCreated().IsZero() {
m.RefreshCreated()
}
m.RefreshUpdated()
if dao.BeforeUpdateFunc != nil {
@@ -162,8 +251,7 @@ func (dao *Dao) update(m models.Model) error {
return err
}
} else {
err := dao.db.Model(m).Update()
if err != nil {
if err := dao.db.Model(m).Update(); err != nil {
return err
}
}
@@ -181,6 +269,9 @@ func (dao *Dao) create(m models.Model) error {
m.RefreshId()
}
// mark the model as "new" since the model now always has an ID
m.MarkAsNew()
if m.GetCreated().IsZero() {
m.RefreshCreated()
}
@@ -197,21 +288,58 @@ func (dao *Dao) create(m models.Model) error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
if _, ok := dataMap["id"]; !ok {
dataMap["id"] = m.GetId()
}
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
if err != nil {
return err
}
} else {
err := dao.db.Model(m).Insert()
if err != nil {
if err := dao.db.Model(m).Insert(); err != nil {
return err
}
}
// clears the "new" model flag
m.MarkAsNotNew()
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(dao, m)
}
return nil
}
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
retryDao := dao
attempts := 1
Retry:
if attempts == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = &Dao{
db: dao.db,
AfterCreateFunc: dao.AfterCreateFunc,
AfterUpdateFunc: dao.AfterUpdateFunc,
AfterDeleteFunc: dao.AfterDeleteFunc,
}
}
// execute
err := op(retryDao)
if err != nil &&
attempts < maxRetries &&
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
attempts++
goto Retry
}
return err
}
+440 -17
View File
@@ -35,8 +35,8 @@ func TestDaoModelQuery(t *testing.T) {
"SELECT {{_collections}}.* FROM `_collections`",
},
{
&models.User{},
"SELECT {{_users}}.* FROM `_users`",
&models.Admin{},
"SELECT {{_admins}}.* FROM `_admins`",
},
{
&models.Request{},
@@ -64,19 +64,19 @@ func TestDaoFindById(t *testing.T) {
// missing id
{
&models.Collection{},
"00000000-075d-49fe-9d09-ea7e951000dc",
"missing",
true,
},
// existing collection id
{
&models.Collection{},
"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"wsmn24bux7wo113",
false,
},
// existing user id
// existing admin id
{
&models.User{},
"97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
&models.Admin{},
"sbmbsdb40jyxf7h",
false,
},
}
@@ -158,6 +158,33 @@ func TestDaoSaveCreate(t *testing.T) {
}
}
func TestDaoSaveWithInsertId(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model := &models.Admin{}
model.Id = "test"
model.Email = "test_new@example.com"
model.MarkAsNew()
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
// refresh
model, _ = testApp.Dao().FindAdminById("test")
if model == nil {
t.Fatal("Failed to find admin with id 'test'")
}
expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"}
for _, h := range expectedHooks {
if v, ok := testApp.EventCalls[h]; !ok || v != 1 {
t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v)
}
}
}
func TestDaoSaveUpdate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -184,6 +211,61 @@ func TestDaoSaveUpdate(t *testing.T) {
}
}
type dummyColumnValueMapper struct {
models.Admin
}
func (a *dummyColumnValueMapper) ColumnValueMap() map[string]any {
return map[string]any{
"email": a.Email,
"passwordHash": a.PasswordHash,
"tokenKey": "custom_token_key",
}
}
func TestDaoSaveWithColumnValueMapper(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model := &dummyColumnValueMapper{}
model.Id = "test_mapped_id" // explicitly set an id
model.Email = "test_mapped_create@example.com"
model.TokenKey = "test_unmapped_token_key" // not used in the map
model.SetPassword("123456")
model.MarkAsNew()
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
createdModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
if createdModel == nil {
t.Fatal("[create] Failed to find model with id 'test_mapped_id'")
}
if createdModel.Email != model.Email {
t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
}
if createdModel.TokenKey != "custom_token_key" {
t.Fatalf("Expected model with tokenKey %q, got %q", "custom_token_key", createdModel.TokenKey)
}
model.Email = "test_mapped_update@example.com"
model.Avatar = 9 // not mapped and expect to be ignored
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
updatedModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
if updatedModel == nil {
t.Fatal("[update] Failed to find model with id 'test_mapped_id'")
}
if updatedModel.Email != model.Email {
t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
}
if updatedModel.Avatar != 0 {
t.Fatalf("Expected model avatar 0, got %v", updatedModel.Avatar)
}
}
func TestDaoDelete(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -207,39 +289,380 @@ func TestDaoDelete(t *testing.T) {
}
}
func TestDaoRetryCreate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init mock retry dao
retryBeforeCreateHookCalls := 0
retryAfterCreateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeCreateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
retryAfterCreateHookCalls++
}
model := &models.Admin{Email: "new@example.com"}
if err := retryDao.Save(model); err != nil {
t.Fatalf("Expected nil after retry, got error: %v", err)
}
// the before hook is expected to be called only once because
// it is ignored after the first "database is locked" error
if retryBeforeCreateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
}
if retryAfterCreateHookCalls != 1 {
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterCreateHookCalls)
}
// with non-locking error
retryBeforeCreateHookCalls = 0
retryAfterCreateHookCalls = 0
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeCreateHookCalls++
return errors.New("non-locking error")
}
dummy := &models.Admin{Email: "test@example.com"}
if err := retryDao.Save(dummy); err == nil {
t.Fatal("Expected error, got nil")
}
if retryBeforeCreateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
}
if retryAfterCreateHookCalls != 0 {
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterCreateHookCalls)
}
}
func TestDaoRetryUpdate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
// init mock retry dao
retryBeforeUpdateHookCalls := 0
retryAfterUpdateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeUpdateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
retryAfterUpdateHookCalls++
}
if err := retryDao.Save(model); err != nil {
t.Fatalf("Expected nil after retry, got error: %v", err)
}
// the before hook is expected to be called only once because
// it is ignored after the first "database is locked" error
if retryBeforeUpdateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
}
if retryAfterUpdateHookCalls != 1 {
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterUpdateHookCalls)
}
// with non-locking error
retryBeforeUpdateHookCalls = 0
retryAfterUpdateHookCalls = 0
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeUpdateHookCalls++
return errors.New("non-locking error")
}
if err := retryDao.Save(model); err == nil {
t.Fatal("Expected error, got nil")
}
if retryBeforeUpdateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
}
if retryAfterUpdateHookCalls != 0 {
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterUpdateHookCalls)
}
}
func TestDaoRetryDelete(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init mock retry dao
retryBeforeDeleteHookCalls := 0
retryAfterDeleteHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeDeleteHookCalls++
return errors.New("database is locked")
}
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
retryAfterDeleteHookCalls++
}
model, _ := retryDao.FindAdminByEmail("test@example.com")
if err := retryDao.Delete(model); err != nil {
t.Fatalf("Expected nil after retry, got error: %v", err)
}
// the before hook is expected to be called only once because
// it is ignored after the first "database is locked" error
if retryBeforeDeleteHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
}
if retryAfterDeleteHookCalls != 1 {
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterDeleteHookCalls)
}
// with non-locking error
retryBeforeDeleteHookCalls = 0
retryAfterDeleteHookCalls = 0
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeDeleteHookCalls++
return errors.New("non-locking error")
}
dummy := &models.Admin{}
dummy.RefreshId()
dummy.MarkAsNotNew()
if err := retryDao.Delete(dummy); err == nil {
t.Fatal("Expected error, got nil")
}
if retryBeforeDeleteHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
}
if retryAfterDeleteHookCalls != 0 {
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterDeleteHookCalls)
}
}
func TestDaoBeforeHooksError(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
testApp.Dao().BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_create")
}
testApp.Dao().BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_update")
}
testApp.Dao().BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_delete")
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
// try to create
// test create error
// ---
newModel := &models.Admin{}
newModel.Email = "test_new@example.com"
if err := testApp.Dao().Save(newModel); err.Error() != "before_create" {
if err := baseDao.Save(newModel); err.Error() != "before_create" {
t.Fatalf("Expected before_create error, got %v", err)
}
// try to update
// test update error
// ---
if err := testApp.Dao().Save(existingModel); err.Error() != "before_update" {
if err := baseDao.Save(existingModel); err.Error() != "before_update" {
t.Fatalf("Expected before_update error, got %v", err)
}
// try to delete
// test delete error
// ---
if err := testApp.Dao().Delete(existingModel); err.Error() != "before_delete" {
if err := baseDao.Delete(existingModel); err.Error() != "before_delete" {
t.Fatalf("Expected before_delete error, got %v", err)
}
}
func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
beforeCreateFuncCalls := 0
beforeUpdateFuncCalls := 0
beforeDeleteFuncCalls := 0
afterCreateFuncCalls := 0
afterUpdateFuncCalls := 0
afterDeleteFuncCalls := 0
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeCreateFuncCalls++
return nil
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeUpdateFuncCalls++
return nil
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeDeleteFuncCalls++
return nil
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
afterCreateFuncCalls++
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
afterUpdateFuncCalls++
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
afterDeleteFuncCalls++
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
// test create
// ---
newModel := &models.Admin{}
newModel.Email = "test_new1@example.com"
newModel.SetPassword("123456")
if err := txDao2.Save(newModel); err != nil {
t.Fatal(err)
}
// test update (twice)
// ---
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
// test delete
// ---
if err := txDao2.Delete(existingModel); err != nil {
t.Fatal(err)
}
return errors.New("test_tx_error")
})
})
if beforeCreateFuncCalls != 1 {
t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
}
if beforeUpdateFuncCalls != 2 {
t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
}
if beforeDeleteFuncCalls != 1 {
t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
}
if afterCreateFuncCalls != 0 {
t.Fatalf("Expected afterCreateFuncCalls to be called 0 times, got %d", afterCreateFuncCalls)
}
if afterUpdateFuncCalls != 0 {
t.Fatalf("Expected afterUpdateFuncCalls to be called 0 times, got %d", afterUpdateFuncCalls)
}
if afterDeleteFuncCalls != 0 {
t.Fatalf("Expected afterDeleteFuncCalls to be called 0 times, got %d", afterDeleteFuncCalls)
}
}
func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
beforeCreateFuncCalls := 0
beforeUpdateFuncCalls := 0
beforeDeleteFuncCalls := 0
afterCreateFuncCalls := 0
afterUpdateFuncCalls := 0
afterDeleteFuncCalls := 0
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeCreateFuncCalls++
return nil
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeUpdateFuncCalls++
return nil
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeDeleteFuncCalls++
return nil
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
afterCreateFuncCalls++
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
afterUpdateFuncCalls++
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
afterDeleteFuncCalls++
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
// test create
// ---
newModel := &models.Admin{}
newModel.Email = "test_new1@example.com"
newModel.SetPassword("123456")
if err := txDao2.Save(newModel); err != nil {
t.Fatal(err)
}
// test update (twice)
// ---
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
// test delete
// ---
if err := txDao2.Delete(existingModel); err != nil {
t.Fatal(err)
}
return nil
})
})
if beforeCreateFuncCalls != 1 {
t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
}
if beforeUpdateFuncCalls != 2 {
t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
}
if beforeDeleteFuncCalls != 1 {
t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
}
if afterCreateFuncCalls != 1 {
t.Fatalf("Expected afterCreateFuncCalls to be called 1 times, got %d", afterCreateFuncCalls)
}
if afterUpdateFuncCalls != 2 {
t.Fatalf("Expected afterUpdateFuncCalls to be called 2 times, got %d", afterUpdateFuncCalls)
}
if afterDeleteFuncCalls != 1 {
t.Fatalf("Expected afterDeleteFuncCalls to be called 1 times, got %d", afterDeleteFuncCalls)
}
}
+167 -38
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
)
// CollectionQuery returns a new Collection select query.
@@ -15,15 +16,31 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Collection{})
}
// FindCollectionByNameOrId finds the first collection by its name or id.
// FindCollectionsByType finds all collections by the given type.
func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
collections := []*models.Collection{}
err := dao.CollectionQuery().
AndWhere(dbx.HashExp{"type": collectionType}).
OrderBy("created ASC").
All(&collections)
if err != nil {
return nil, err
}
return collections, nil
}
// FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.
func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
model := &models.Collection{}
err := dao.CollectionQuery().
AndWhere(dbx.Or(
dbx.HashExp{"id": nameOrId},
dbx.HashExp{"name": nameOrId},
)).
AndWhere(dbx.NewExp("[[id]] = {:id} OR LOWER([[name]])={:name}", dbx.Params{
"id": nameOrId,
"name": strings.ToLower(nameOrId),
})).
Limit(1).
One(model)
@@ -37,54 +54,42 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case sensitive check because the name is used also as a table name for the records.
func (dao *Dao) IsCollectionNameUnique(name string, excludeId string) bool {
// Note: case insensitive check because the name is used also as a table name for the records.
func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
}
var exists bool
err := dao.CollectionQuery().
query := dao.CollectionQuery().
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1).
Row(&exists)
Limit(1)
return err == nil && !exists
}
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
// FindCollectionsWithUserFields finds all collections that has
// at least one user schema field.
func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
result := []*models.Collection{}
var exists bool
err := dao.CollectionQuery().
InnerJoin(
"json_each(schema) as jsonField",
dbx.NewExp(
"json_extract(jsonField.value, '$.type') = {:type}",
dbx.Params{"type": schema.FieldTypeUser},
),
).
All(&result)
return result, err
return query.Row(&exists) == nil && !exists
}
// FindCollectionReferences returns information for all
// relation schema fields referencing the provided collection.
//
// If the provided collection has reference to itself then it will be
// also included in the result. To exlude it, pass the collection id
// also included in the result. To exclude it, pass the collection id
// as the excludeId argument.
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeId string) (map[*models.Collection][]*schema.SchemaField, error) {
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...string) (map[*models.Collection][]*schema.SchemaField, error) {
collections := []*models.Collection{}
err := dao.CollectionQuery().
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
All(&collections)
if err != nil {
query := dao.CollectionQuery()
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
if err := query.All(&collections); err != nil {
return nil, err
}
@@ -113,7 +118,7 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
// - is referenced as part of a relation field in another collection
func (dao *Dao) DeleteCollection(collection *models.Collection) error {
if collection.System {
return errors.New("System collections cannot be deleted.")
return fmt.Errorf("System collection %q cannot be deleted.", collection.Name)
}
// ensure that there aren't any existing references.
@@ -123,7 +128,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
return err
}
if total := len(result); total > 0 {
return fmt.Errorf("The collection has external relation field references (%d).", total)
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total)
}
return dao.RunInTransaction(func(txDao *Dao) error {
@@ -141,7 +146,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection
if collection.HasId() {
if !collection.IsNew() {
// get the existing collection state to compare with the new one
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
var findErr error
@@ -152,6 +157,11 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
}
return dao.RunInTransaction(func(txDao *Dao) error {
// set default collection type
if collection.Type == "" {
collection.Type = models.CollectionTypeBase
}
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
@@ -161,3 +171,122 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
return txDao.SyncRecordTableSchema(collection, oldCollection)
})
}
// ImportCollections imports the provided collections list within a single transaction.
//
// NB1! If deleteMissing is set, all local collections and schema fields, that are not present
// in the imported configuration, WILL BE DELETED (including their related records data).
//
// NB2! This method doesn't perform validations on the imported collections data!
// If you need validations, use [forms.CollectionsImport].
func (dao *Dao) ImportCollections(
importedCollections []*models.Collection,
deleteMissing bool,
beforeRecordsSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
) error {
if len(importedCollections) == 0 {
return errors.New("No collections to import")
}
return dao.RunInTransaction(func(txDao *Dao) error {
existingCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&existingCollections); err != nil {
return err
}
mappedExisting := make(map[string]*models.Collection, len(existingCollections))
for _, existing := range existingCollections {
mappedExisting[existing.GetId()] = existing
}
mappedImported := make(map[string]*models.Collection, len(importedCollections))
for _, imported := range importedCollections {
// generate id if not set
if !imported.HasId() {
imported.MarkAsNew()
imported.RefreshId()
}
// set default type if missing
if imported.Type == "" {
imported.Type = models.CollectionTypeBase
}
if existing, ok := mappedExisting[imported.GetId()]; ok {
imported.MarkAsNotNew()
// preserve original created date
if !existing.Created.IsZero() {
imported.Created = existing.Created
}
// extend existing schema
if !deleteMissing {
schema, _ := existing.Schema.Clone()
for _, f := range imported.Schema.Fields() {
schema.AddField(f) // add or replace
}
imported.Schema = *schema
}
} else {
imported.MarkAsNew()
}
mappedImported[imported.GetId()] = imported
}
// delete old collections not available in the new configuration
// (before saving the imports in case a deleted collection name is being reused)
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
if existing.System {
return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
}
// delete the collection
if err := txDao.Delete(existing); err != nil {
return err
}
}
}
// upsert imported collections
for _, imported := range importedCollections {
if err := txDao.Save(imported); err != nil {
return err
}
}
if beforeRecordsSync != nil {
if err := beforeRecordsSync(txDao, mappedImported, mappedExisting); err != nil {
return err
}
}
// delete the record tables of the deleted collections
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
}
}
// sync the upserted collections with the related records table
for _, imported := range importedCollections {
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
}
}
return nil
})
}
+340 -37
View File
@@ -1,8 +1,12 @@
package daos_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
@@ -21,6 +25,41 @@ func TestCollectionQuery(t *testing.T) {
}
}
func TestFindCollectionsByType(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionType string
expectError bool
expectTotal int
}{
{"", false, 0},
{"unknown", false, 0},
{models.CollectionTypeAuth, false, 3},
{models.CollectionTypeBase, false, 4},
}
for i, scenario := range scenarios {
collections, err := app.Dao().FindCollectionsByType(scenario.collectionType)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if len(collections) != scenario.expectTotal {
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
}
for _, c := range collections {
if c.Type != scenario.collectionType {
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
}
}
}
}
func TestFindCollectionByNameOrId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -31,9 +70,9 @@ func TestFindCollectionByNameOrId(t *testing.T) {
}{
{"", true},
{"missing", true},
{"00000000-075d-49fe-9d09-ea7e951000dc", true},
{"3f2888f8-075d-49fe-9d09-ea7e951000dc", false},
{"demo", false},
{"wsmn24bux7wo113", false},
{"demo1", false},
{"DEMO1", false}, // case insensitive check
}
for i, scenario := range scenarios {
@@ -44,7 +83,7 @@ func TestFindCollectionByNameOrId(t *testing.T) {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if model != nil && model.Id != scenario.nameOrId && model.Name != scenario.nameOrId {
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
}
}
@@ -60,9 +99,10 @@ func TestIsCollectionNameUnique(t *testing.T) {
expected bool
}{
{"", "", false},
{"demo", "", false},
{"demo1", "", false},
{"Demo1", "", false},
{"new", "", true},
{"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true},
{"demo1", "wsmn24bux7wo113", true},
}
for i, scenario := range scenarios {
@@ -73,33 +113,11 @@ func TestIsCollectionNameUnique(t *testing.T) {
}
}
func TestFindCollectionsWithUserFields(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
result, err := app.Dao().FindCollectionsWithUserFields()
if err != nil {
t.Fatal(err)
}
expectedNames := []string{"demo2", models.ProfileCollectionName}
if len(result) != len(expectedNames) {
t.Fatalf("Expected collections %v, got %v", expectedNames, result)
}
for i, col := range result {
if !list.ExistInSlice(col.Name, expectedNames) {
t.Errorf("(%d) Couldn't find %s in %v", i, col.Name, expectedNames)
}
}
}
func TestFindCollectionReferences(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo")
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
@@ -113,11 +131,18 @@ func TestFindCollectionReferences(t *testing.T) {
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
}
expectedFields := []string{"onerel", "manyrels", "rel_cascade"}
expectedFields := []string{
"rel_one_no_cascade",
"rel_one_no_cascade_required",
"rel_one_cascade",
"rel_many_no_cascade",
"rel_many_no_cascade_required",
"rel_many_cascade",
}
for col, fields := range result {
if col.Name != "demo2" {
t.Fatalf("Expected collection demo2, got %s", col.Name)
if col.Name != "demo4" {
t.Fatalf("Expected collection demo4, got %s", col.Name)
}
if len(fields) != len(expectedFields) {
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
@@ -135,7 +160,7 @@ func TestDeleteCollection(t *testing.T) {
defer app.Cleanup()
c0 := &models.Collection{}
c1, err := app.Dao().FindCollectionByNameOrId("demo")
c1, err := app.Dao().FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
@@ -143,18 +168,22 @@ func TestDeleteCollection(t *testing.T) {
if err != nil {
t.Fatal(err)
}
c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
c3.System = true
if err := app.Dao().Save(c3); err != nil {
t.Fatal(err)
}
scenarios := []struct {
model *models.Collection
expectError bool
}{
{c0, true},
{c1, true}, // is part of a reference
{c2, false},
{c1, false},
{c2, true}, // is part of a reference
{c3, true}, // system
}
@@ -174,6 +203,7 @@ func TestSaveCollectionCreate(t *testing.T) {
collection := &models.Collection{
Name: "new_test",
Type: models.CollectionTypeBase,
Schema: schema.NewSchema(
&schema.SchemaField{
Type: schema.FieldTypeText,
@@ -236,7 +266,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
// check if the records table has the schema fields
expectedColumns := []string{"id", "created", "updated", "title_update", "test"}
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
columns, err := app.Dao().GetTableColumns(collection.Name)
if err != nil {
t.Fatal(err)
@@ -250,3 +280,276 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
}
}
func TestImportCollections(t *testing.T) {
scenarios := []struct {
name string
jsonData string
deleteMissing bool
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
expectError bool
expectCollectionsCount int
beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
}{
{
name: "empty collections",
jsonData: `[]`,
expectError: true,
expectCollectionsCount: 7,
},
{
name: "minimal collection import",
jsonData: `[
{"name": "import_test1", "schema": [{"name":"test", "type": "text"}]},
{"name": "import_test2", "type": "auth"}
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 9,
},
{
name: "minimal collection import + failed beforeRecordsSync",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
return errors.New("test_error")
},
deleteMissing: false,
expectError: true,
expectCollectionsCount: 7,
},
{
name: "minimal collection import + successful beforeRecordsSync",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
return nil
},
deleteMissing: false,
expectError: false,
expectCollectionsCount: 8,
},
{
name: "new + update + delete system collection",
jsonData: `[
{
"id":"wsmn24bux7wo113",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: true,
expectError: true,
expectCollectionsCount: 7,
},
{
name: "new + update + delete non-system collection",
jsonData: `[
{
"id": "kpv709sk2lqbqk8",
"system": true,
"name": "nologin",
"type": "auth",
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": [],
"manageRule": "@request.auth.collectionName = 'users'",
"minPasswordLength": 8,
"onlyEmailDomains": [],
"requireEmail": true
},
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"schema": [
{
"id": "x8zzktwe",
"name": "name",
"type": "text",
"system": false,
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
]
},
{
"id":"wsmn24bux7wo113",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"id": "test_deleted_collection_name_reuse",
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: true,
expectError: false,
expectCollectionsCount: 3,
},
{
name: "test with deleteMissing: false",
jsonData: `[
{
"id":"wsmn24bux7wo113",
"name":"demo1",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
},
{
"id":"_2hlxbmp",
"name":"field_with_duplicate_id",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
},
{
"id":"abcd_import",
"name":"new_field",
"type":"text"
}
]
},
{
"name": "new_import",
"schema": [
{
"id":"abcd_import",
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 8,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
"nologin": 1,
"demo1": 15,
"demo2": 2,
"demo3": 2,
"demo4": 11,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
collection, err := testApp.Dao().FindCollectionByNameOrId(name)
if err != nil {
t.Fatal(err)
}
if totalFields := len(collection.Schema.Fields()); totalFields != expectedCount {
t.Errorf("Expected %d %q fields, got %d", expectedCount, collection.Name, totalFields)
}
}
},
},
}
for _, scenario := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
importedCollections := []*models.Collection{}
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections)
if loadErr != nil {
t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr)
continue
}
err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != scenario.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections))
}
if scenario.afterTestFunc != nil {
scenario.afterTestFunc(testApp, collections)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package daos
import (
"errors"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
)
// ExternalAuthQuery returns a new ExternalAuth select query.
func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.ExternalAuth{})
}
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
/// linked to the provided auth record.
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
auths := []*models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.HashExp{
"collectionId": authRecord.Collection().Id,
"recordId": authRecord.Id,
}).
OrderBy("created ASC").
All(&auths)
if err != nil {
return nil, err
}
return auths, nil
}
// FindExternalAuthByProvider returns the first available
// ExternalAuth model for the specified provider and providerId.
func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
model := &models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
AndWhere(dbx.HashExp{
"provider": provider,
"providerId": providerId,
}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
// FindExternalAuthByRecordAndProvider returns the first available
// ExternalAuth model for the specified record data and provider.
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
model := &models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.HashExp{
"collectionId": authRecord.Collection().Id,
"recordId": authRecord.Id,
"provider": provider,
}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
return model, nil
}
// SaveExternalAuth upserts the provided ExternalAuth model.
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
// extra check the model data in case the provider's API response
// has changed and no longer returns the expected fields
if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
return errors.New("Missing required ExternalAuth fields.")
}
return dao.Save(model)
}
// DeleteExternalAuth deletes the provided ExternalAuth model.
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
return dao.Delete(model)
}
+189
View File
@@ -0,0 +1,189 @@
package daos_test
import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestExternalAuthQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
expected := "SELECT {{_externalAuths}}.* FROM `_externalAuths`"
sql := app.Dao().ExternalAuthQuery().Build().SQL()
if sql != expected {
t.Errorf("Expected sql %s, got %s", expected, sql)
}
}
func TestFindAllExternalAuthsByRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
userId string
expectedCount int
}{
{"oap640cot4yru2s", 0},
{"4q1xlclmfloku33", 2},
}
for i, s := range scenarios {
record, err := app.Dao().FindRecordById("users", s.userId)
if err != nil {
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
continue
}
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Errorf("(%d) Unexpected auths fetch error %v", i, err)
continue
}
if len(auths) != s.expectedCount {
t.Errorf("(%d) Expected %d auths, got %d", i, s.expectedCount, len(auths))
}
for _, auth := range auths {
if auth.RecordId != record.Id {
t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
}
}
}
}
func TestFindExternalAuthByProvider(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
provider string
providerId string
expectedId string
}{
{"", "", ""},
{"github", "", ""},
{"github", "id1", ""},
{"github", "id2", ""},
{"google", "test123", "clmflokuq1xl341"},
{"gitlab", "test123", "dlmflokuq1xl342"},
}
for i, s := range scenarios {
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
hasErr := err != nil
expectErr := s.expectedId == ""
if hasErr != expectErr {
t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
continue
}
if auth != nil && auth.Id != s.expectedId {
t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
}
}
}
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
userId string
provider string
expectedId string
}{
{"bgs820n361vj1qd", "google", ""},
{"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
{"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
}
for i, s := range scenarios {
record, err := app.Dao().FindRecordById("users", s.userId)
if err != nil {
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
continue
}
auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider)
hasErr := err != nil
expectErr := s.expectedId == ""
if hasErr != expectErr {
t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
continue
}
if auth != nil && auth.Id != s.expectedId {
t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
}
}
}
func TestSaveExternalAuth(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// save with empty provider data
emptyAuth := &models.ExternalAuth{}
if err := app.Dao().SaveExternalAuth(emptyAuth); err == nil {
t.Fatal("Expected error, got nil")
}
auth := &models.ExternalAuth{
RecordId: "o1y0dd0spd786md",
CollectionId: "v851q4r790rhknl",
Provider: "test",
ProviderId: "test_id",
}
if err := app.Dao().SaveExternalAuth(auth); err != nil {
t.Fatal(err)
}
// check if it was really saved
foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
if err != nil {
t.Fatal(err)
}
if auth.Id != foundAuth.Id {
t.Fatalf("Expected ExternalAuth with id %s, got \n%v", auth.Id, foundAuth)
}
}
func TestDeleteExternalAuth(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
}
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Fatal(err)
}
for _, auth := range auths {
if err := app.Dao().DeleteExternalAuth(auth); err != nil {
t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
}
}
// check if the relations were really deleted
newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Fatal(err)
}
if len(newAuths) != 0 {
t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
}
}
+1 -3
View File
@@ -40,7 +40,7 @@ func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) err
param = &models.Param{Key: key}
}
var normalizedValue any
normalizedValue := value
// encrypt if optEncryptionKey is set
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
@@ -55,8 +55,6 @@ func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) err
}
normalizedValue = encryptVal
} else {
normalizedValue = value
}
encodedValue := types.JsonRaw{}
+347 -107
View File
@@ -1,15 +1,20 @@
package daos
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// RecordQuery returns a new Record select query.
@@ -22,16 +27,24 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
// FindRecordById finds the Record model by its id.
func (dao *Dao) FindRecordById(
collection *models.Collection,
collectionNameOrId string,
recordId string,
filter func(q *dbx.SelectQuery) error,
optFilters ...func(q *dbx.SelectQuery) error,
) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
tableName := collection.Name
query := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{tableName + ".id": recordId})
if filter != nil {
for _, filter := range optFilters {
if filter == nil {
continue
}
if err := filter(query); err != nil {
return nil, err
}
@@ -48,16 +61,25 @@ func (dao *Dao) FindRecordById(
// FindRecordsByIds finds all Record models by the provided ids.
// If no records are found, returns an empty slice.
func (dao *Dao) FindRecordsByIds(
collection *models.Collection,
collectionNameOrId string,
recordIds []string,
filter func(q *dbx.SelectQuery) error,
optFilters ...func(q *dbx.SelectQuery) error,
) ([]*models.Record, error) {
tableName := collection.Name
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
query := dao.RecordQuery(collection).
AndWhere(dbx.In(tableName+".id", list.ToInterfaceSlice(recordIds)...))
AndWhere(dbx.In(
collection.Name+".id",
list.ToInterfaceSlice(recordIds)...,
))
if filter != nil {
for _, filter := range optFilters {
if filter == nil {
continue
}
if err := filter(query); err != nil {
return nil, err
}
@@ -71,24 +93,34 @@ func (dao *Dao) FindRecordsByIds(
return models.NewRecordsFromNullStringMaps(collection, rows), nil
}
// FindRecordsByExpr finds all records by the provided db expression.
// If no records are found, returns an empty slice.
// FindRecordsByExpr finds all records by the specified db expression.
//
// Returns all collection records if no expressions are provided.
//
// Returns an empty slice if no records are found.
//
// Example:
// expr := dbx.HashExp{"email": "test@example.com"}
// dao.FindRecordsByExpr(collection, expr)
func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expression) ([]*models.Record, error) {
if expr == nil {
return nil, errors.New("Missing filter expression")
// expr1 := dbx.HashExp{"email": "test@example.com"}
// 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)
// add only the non-nil expressions
for _, expr := range exprs {
if expr != nil {
query.AndWhere(expr)
}
}
rows := []dbx.NullStringMap{}
err := dao.RecordQuery(collection).
AndWhere(expr).
All(&rows)
if err != nil {
if err := query.All(&rows); err != nil {
return nil, err
}
@@ -97,11 +129,16 @@ func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expres
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string, value any) (*models.Record, error) {
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
row := dbx.NullStringMap{}
err := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{key: value}).
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(row)
@@ -114,81 +151,193 @@ func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string,
// 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",
// the unique check will be case insensitive.
//
// NB! Array values (eg. from multiple select fields) are matched
// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
// depends on the elements order. Or in other words the following values
// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
func (dao *Dao) IsRecordValueUnique(
collection *models.Collection,
collectionNameOrId string,
key string,
value any,
excludeId string,
excludeIds ...string,
) bool {
var exists bool
var normalizedVal any
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
default:
normalizedVal = val
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return false
}
err := dao.RecordQuery(collection).
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.HashExp{key: normalizedVal}).
Limit(1).
Row(&exists)
var expr dbx.Expression
if collection.IsAuth() && key == schema.FieldNameUsername {
expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(cast.ToString(value)),
})
} else {
var normalizedVal any
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
default:
normalizedVal = val
}
return err == nil && !exists
expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
}
query := dao.RecordQuery(collection).
Select("count(*)").
AndWhere(expr).
Limit(1)
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
return query.Row(&exists) == nil && !exists
}
// FindUserRelatedRecords returns all records that has a reference
// to the provided User model (via the user shema field).
func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) {
collections, err := dao.FindCollectionsWithUserFields()
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
//
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
}
result := []*models.Record{}
for _, collection := range collections {
userFields := []*schema.SchemaField{}
// prepare fields options
if err := collection.Schema.InitFieldsOptions(); err != nil {
return nil, err
}
// extract user fields
for _, field := range collection.Schema.Fields() {
if field.Type == schema.FieldTypeUser {
userFields = append(userFields, field)
}
}
// fetch records associated to the user
exprs := []dbx.Expression{}
for _, field := range userFields {
exprs = append(exprs, dbx.HashExp{field.Name: user.Id})
}
rows := []dbx.NullStringMap{}
if err := dao.RecordQuery(collection).AndWhere(dbx.Or(exprs...)).All(&rows); err != nil {
return nil, err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
result = append(result, records...)
// check required claims
id, _ := unverifiedClaims["id"].(string)
collectionId, _ := unverifiedClaims["collectionId"].(string)
if id == "" || collectionId == "" {
return nil, errors.New("Missing or invalid token claims.")
}
return result, nil
record, err := dao.FindRecordById(collectionId, id)
if err != nil {
return nil, err
}
if !record.Collection().IsAuth() {
return nil, errors.New("The token is not associated to an auth collection record.")
}
verificationKey := record.TokenKey() + baseTokenKey
// verify token signature
if _, err := security.ParseJWT(token, verificationKey); err != nil {
return nil, err
}
return record, nil
}
// FindAuthRecordByEmail finds the auth record associated with the provided email.
//
// Returns an error if it is not an auth collection or the record is not found.
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil || !collection.IsAuth() {
return nil, errors.New("Missing or not an auth collection.")
}
row := dbx.NullStringMap{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
Limit(1).
One(row)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
}
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
//
// Returns an error if it is not an auth collection or the record is not found.
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil || !collection.IsAuth() {
return nil, errors.New("Missing or not an auth collection.")
}
row := dbx.NullStringMap{}
err = dao.RecordQuery(collection).
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(username),
})).
Limit(1).
One(row)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
}
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
// and return a new "unique" username with appended random numeric part
// (eg. "existingName" -> "existingName583").
//
// The same username will be returned if the provided string is already unique.
func (dao *Dao) SuggestUniqueAuthRecordUsername(
collectionNameOrId string,
baseUsername string,
excludeIds ...string,
) string {
username := baseUsername
for i := 0; i < 10; i++ { // max 10 attempts
isUnique := dao.IsRecordValueUnique(
collectionNameOrId,
schema.FieldNameUsername,
username,
excludeIds...,
)
if isUnique {
break // already unique
}
username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789")
}
return username
}
// SaveRecord upserts the provided Record model.
func (dao *Dao) SaveRecord(record *models.Record) error {
if record.Collection().IsAuth() {
if record.Username() == "" {
return errors.New("unable to save auth record without username")
}
// Cross-check that the auth record id is unique for all auth collections.
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
if err != nil {
return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
}
for _, collection := range authCollections {
if record.Collection().Id == collection.Id {
continue // skip current collection (sqlite will do the check for us)
}
isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
if !isUnique {
return errors.New("the auth record ID must be unique across all auth collections")
}
}
}
return dao.Save(record)
}
@@ -200,28 +349,55 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
// The delete operation may fail if the record is part of a required
// reference in another record (aka. cannot be deleted or set to NULL).
func (dao *Dao) DeleteRecord(record *models.Record) error {
// check for references
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
refs, err := dao.FindCollectionReferences(record.Collection(), "")
// fetch rel references (if any)
//
// note: the select is outside of the transaction to minimize
// SQLITE_BUSY errors when mixing read&write in a single transaction
refs, err := dao.FindCollectionReferences(record.Collection())
if err != nil {
return err
}
// check if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the record id from any relation field values (if they are not required)
// -----------------------------------------------------------
// run all consequent DeleteRecord requests synchroniously
// to minimize SQLITE_BUSY errors
if len(refs) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := dao.Block(ctx); err != nil {
return err
}
defer dao.Continue()
}
return dao.RunInTransaction(func(txDao *Dao) error {
// always delete the record first to ensure that there will be no "A<->B"
// relations to prevent deadlock when calling DeleteRecord recursively
if err := txDao.Delete(record); err != nil {
return err
}
// check if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the record id from any relation field values (if they are not required)
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(5)
for refCollection, fields := range refs {
for _, field := range fields {
options, _ := field.Options.(*schema.RelationOptions)
rows := []dbx.NullStringMap{}
// note: the select is not using the transaction dao to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
err := dao.RecordQuery(refCollection).
AndWhere(dbx.Not(dbx.HashExp{"id": record.Id})).
AndWhere(dbx.Like(field.Name, record.Id).Match(true, true)).
// fetch all referenced records
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
err := txDao.RecordQuery(refCollection).
Distinct(true).
LeftJoin(fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), nil).
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": record.Id})).
AndWhere(dbx.HashExp{uniqueJsonEachAlias + ".value": record.Id}).
All(&rows)
if err != nil {
return err
@@ -229,7 +405,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
for _, refRecord := range refRecords {
ids := refRecord.GetStringSliceDataValue(field.Name)
ids := refRecord.GetStringSlice(field.Name)
// unset the record id
for i := len(ids) - 1; i >= 0; i-- {
@@ -245,7 +421,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
if err := txDao.DeleteRecord(refRecord); err != nil {
return err
}
// no further action are needed (the reference is deleted)
// no further actions are needed (the reference is deleted)
continue
}
@@ -254,7 +430,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
}
// save the reference changes
refRecord.SetDataValue(field.Name, field.PrepareValue(ids))
refRecord.Set(field.Name, field.PrepareValue(ids))
if err := txDao.SaveRecord(refRecord); err != nil {
return err
}
@@ -262,7 +438,18 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
}
}
return txDao.Delete(record)
// delete linked external auths
if record.Collection().IsAuth() {
_, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{
"collectionId": record.Collection().Id,
"recordId": record.Id,
}).Execute()
if err != nil {
return err
}
}
return nil
})
}
@@ -274,9 +461,26 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// create
if oldCollection == nil {
cols := map[string]string{
schema.ReservedFieldNameId: "TEXT PRIMARY KEY",
schema.ReservedFieldNameCreated: `TEXT DEFAULT "" NOT NULL`,
schema.ReservedFieldNameUpdated: `TEXT DEFAULT "" NOT NULL`,
schema.FieldNameId: "TEXT PRIMARY KEY",
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
}
if newCollection.IsAuth() {
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
}
// ensure that the new collection has an id
if !newCollection.HasId() {
newCollection.RefreshId()
newCollection.MarkAsNew()
}
tableName := newCollection.Name
@@ -287,15 +491,30 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
// create table
_, tableErr := dao.DB().CreateTable(tableName, cols).Execute()
if tableErr != nil {
return tableErr
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add index on the base `created` column
_, indexErr := dao.DB().CreateIndex(tableName, tableName+"_created_idx", "created").Execute()
if indexErr != nil {
return indexErr
// add named index on the base `created` column
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := dao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
return err
}
}
return nil
@@ -310,7 +529,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
if err != nil {
return err
}
@@ -329,23 +548,44 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
// check for new or renamed columns
toRename := map[string]string{}
for _, field := range newSchema.Fields() {
oldField := oldSchema.GetFieldById(field.Id)
if oldField != nil {
// rename
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, field.Name).Execute()
// Note:
// We are using a temporary column name when adding or renaming columns
// to ensure that there are no name collisions in case there is
// names switch/reuse of existing columns (eg. name, title -> title, name).
// This way we are always doing 1 more rename operation but it provides better dev experience.
if oldField == nil {
tempName := field.Name + security.PseudorandomString(5)
toRename[tempName] = field.Name
// add
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
if err != nil {
return err
}
} else {
// add
_, err := txDao.DB().AddColumn(newTableName, field.Name, field.ColDefinition()).Execute()
} else if oldField.Name != field.Name {
tempName := field.Name + security.PseudorandomString(5)
toRename[tempName] = field.Name
// rename
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
if err != nil {
return err
}
}
}
// set the actual columns name
for tempName, actualName := range toRename {
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
if err != nil {
return err
}
}
return nil
})
}
+150 -31
View File
@@ -3,11 +3,16 @@ package daos
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
)
// MaxExpandDepth specifies the max allowed nested expand depth path.
@@ -17,27 +22,36 @@ const MaxExpandDepth = 6
type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]*models.Record, error)
// ExpandRecord expands the relations of a single Record model.
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) error {
//
// 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)
}
// ExpandRecords expands the relations of the provided Record models list.
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) error {
//
// 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 {
normalized := normalizeExpands(expands)
failed := map[string]error{}
for _, expand := range normalized {
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
return err
failed[expand] = err
}
}
return nil
return failed
}
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
// notes:
// - fetchFunc must be non-nil func
// - 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.")
@@ -47,26 +61,104 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
return nil
}
parts := strings.SplitN(expandPath, ".", 2)
// extract the relation field (if exist)
mainCollection := records[0].Collection()
relField := mainCollection.Schema.GetFieldByName(parts[0])
if relField == nil {
return fmt.Errorf("Couldn't find field %q in collection %q.", parts[0], mainCollection.Name)
}
relField.InitOptions()
relFieldOptions, _ := relField.Options.(*schema.RelationOptions)
relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
if err != nil {
return fmt.Errorf("Couldn't find collection %q.", relFieldOptions.CollectionId)
var relField *schema.SchemaField
var relFieldOptions *schema.RelationOptions
var relCollection *models.Collection
parts := strings.SplitN(expandPath, ".", 2)
matches := indirectExpandRegex.FindStringSubmatch(parts[0])
if len(matches) == 3 {
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
if indirectRel == nil {
return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
}
indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
}
indirectRelField.InitOptions()
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
}
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
// for now don't allow multi-relation indirect fields expand
// due to eventual poor query performance with large data sets.
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
}
recordIds := make([]any, len(records))
for i, record := range records {
recordIds[i] = record.Id
}
indirectRecords, err := dao.FindRecordsByExpr(
indirectRel.Id,
dbx.In(inflector.Columnify(matches[2]), recordIds...),
)
if err != nil {
return err
}
mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
for _, indirectRecord := range indirectRecords {
recId := indirectRecord.GetString(matches[2])
if recId != "" {
mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
}
}
// add the indirect relation ids as a new relation field value
for _, record := range records {
relIds, ok := mappedIndirectRecordIds[record.Id]
if ok && len(relIds) > 0 {
record.Set(parts[0], relIds)
}
}
relFieldOptions = &schema.RelationOptions{
MaxSelect: nil,
CollectionId: indirectRel.Id,
}
if indirectRelField.Unique {
relFieldOptions.MaxSelect = types.Pointer(1)
}
// indirect relation
relField = &schema.SchemaField{
Id: "indirect_" + security.PseudorandomString(5),
Type: schema.FieldTypeRelation,
Name: parts[0],
Options: relFieldOptions,
}
relCollection = indirectRel
} else {
// direct relation
relField = mainCollection.Schema.GetFieldByName(parts[0])
if relField == nil || relField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
}
relField.InitOptions()
relFieldOptions, _ = relField.Options.(*schema.RelationOptions)
if relFieldOptions == nil {
return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0])
}
relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
if relCollection == nil {
return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId)
}
}
// ---------------------------------------------------------------
// extract the id of the relations to expand
relIds := []string{}
relIds := make([]string, 0, len(records))
for _, record := range records {
relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...)
relIds = append(relIds, record.GetStringSlice(relField.Name)...)
}
// fetch rels
@@ -90,9 +182,9 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
for _, model := range records {
relIds := model.GetStringSliceDataValue(relField.Name)
relIds := model.GetStringSlice(relField.Name)
validRels := []*models.Record{}
validRels := make([]*models.Record, 0, len(relIds))
for _, id := range relIds {
if rel, ok := indexedRels[id]; ok {
validRels = append(validRels, rel)
@@ -103,14 +195,43 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
continue // no valid relations
}
expandData := model.GetExpand()
expandData := model.Expand()
// normalize and set the expanded relations
if relFieldOptions.MaxSelect == 1 {
// normalize access to the previously expanded rel records (if any)
var oldExpandedRels []*models.Record
switch v := expandData[relField.Name].(type) {
case nil:
// no old expands
case *models.Record:
oldExpandedRels = []*models.Record{v}
case []*models.Record:
oldExpandedRels = v
}
// merge expands
for _, oldExpandedRel := range oldExpandedRels {
// find a matching rel record
for _, rel := range validRels {
if rel.Id != oldExpandedRel.Id {
continue
}
oldRelExpand := oldExpandedRel.Expand()
newRelExpand := rel.Expand()
for k, v := range oldRelExpand {
newRelExpand[k] = v
}
rel.SetExpand(newRelExpand)
}
}
// update the expanded data
if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
expandData[relField.Name] = validRels[0]
} else {
expandData[relField.Name] = validRels
}
model.SetExpand(expandData)
}
@@ -120,20 +241,18 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
// normalizeExpands normalizes expand strings and merges self containing paths
// (eg. ["a.b.c", "a.b", " test ", " ", "test"] -> ["a.b.c", "test"]).
func normalizeExpands(paths []string) []string {
result := []string{}
// normalize paths
normalized := []string{}
normalized := make([]string, 0, len(paths))
for _, p := range paths {
p := strings.ReplaceAll(p, " ", "") // replace spaces
p = strings.Trim(p, ".") // trim incomplete paths
if p == "" {
continue
p = strings.ReplaceAll(p, " ", "") // replace spaces
p = strings.Trim(p, ".") // trim incomplete paths
if p != "" {
normalized = append(normalized, p)
}
normalized = append(normalized, p)
}
// merge containing paths
result := make([]string, 0, len(normalized))
for i, p1 := range normalized {
var skip bool
for j, p2 := range normalized {
+218 -128
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -16,131 +17,173 @@ func TestExpandRecords(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct {
recordIds []string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectError bool
testName string
collectionIdOrName string
recordIds []string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
// empty records
{
"empty records",
"",
[]string{},
[]string{"onerel", "manyrels.onerel.manyrels"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
false,
0,
},
// empty expand
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
"empty expand",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
false,
0,
},
// empty fetchFunc
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"onerel", "manyrels.onerel.manyrels"},
"empty fetchFunc",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
true,
2,
},
// fetchFunc with error
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"onerel", "manyrels.onerel.manyrels"},
"fetchFunc with error",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error")
},
0,
true,
2,
},
// invalid missing first level expand
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"invalid"},
"missing relation field",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
true,
1,
},
// invalid missing second level expand
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"manyrels.invalid"},
"existing, but non-relation type field",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
true,
1,
},
// expand normalizations
{
"invalid/missing second level expand",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
{
"expand normalizations",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
"b84cd893-7119-43c9-8505-3c4e22da28a9",
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
"self_rel_many", "self_rel_many.",
" self_rel_many ", "",
},
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel", "onerel", "onerel.", " onerel ", ""},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
9,
false,
0,
},
// single expand
{
"single expand",
"users",
[]string{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
"b84cd893-7119-43c9-8505-3c4e22da28a9", // no manyrels
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", // no manyrels
"bgs820n361vj1qd",
"4q1xlclmfloku33",
"oap640cot4yru2s", // no rels
},
[]string{"manyrels"},
[]string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
2,
false,
0,
},
// maxExpandDepth reached
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"},
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
"maxExpandDepth reached",
"demo4",
[]string{"qzaqccwrmva4o1n"},
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
6,
false,
0,
},
{
"simple indirect expand",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{"demo4(rel_one_no_cascade_required)"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
1,
0,
},
{
"nested indirect expand",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
5,
0,
},
}
for i, s := range scenarios {
for _, s := range scenarios {
ids := list.ToUniqueStringSlice(s.recordIds)
records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
err := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
if len(failed) != s.expectExpandFailures {
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(records)
encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, "@expand")
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps)
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
@@ -149,110 +192,157 @@ func TestExpandRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct {
recordId string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectError bool
testName string
collectionIdOrName string
recordId string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
// empty expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
"empty expand",
"demo4",
"i9naidtvr6qsgb4",
[]string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
false,
0,
},
// empty fetchFunc
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"onerel", "manyrels.onerel.manyrels"},
"empty fetchFunc",
"demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
true,
2,
},
// fetchFunc with error
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"onerel", "manyrels.onerel.manyrels"},
"fetchFunc with error",
"demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error")
},
0,
true,
2,
},
// invalid missing first level expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"invalid"},
"missing relation field",
"demo4",
"i9naidtvr6qsgb4",
[]string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
true,
},
// invalid missing second level expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels.invalid"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
true,
},
// expand normalizations
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
3,
false,
},
// single expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
1,
false,
},
// maxExpandDepth reached
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
"existing, but non-relation type field",
"demo4",
"i9naidtvr6qsgb4",
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
{
"invalid/missing second level expand",
"demo4",
"qzaqccwrmva4o1n",
[]string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
{
"expand normalizations",
"demo4",
"qzaqccwrmva4o1n",
[]string{
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
"self_rel_many", "self_rel_many.",
" self_rel_many ", "",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
8,
0,
},
{
"no rels to expand",
"users",
"oap640cot4yru2s",
[]string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
{
"maxExpandDepth reached",
"demo4",
"qzaqccwrmva4o1n",
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
6,
false,
0,
},
{
"simple indirect expand",
"demo3",
"lcl9d87w22ml6jy",
[]string{"demo4(rel_one_no_cascade_required)"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
1,
0,
},
{
"nested indirect expand",
"demo3",
"lcl9d87w22ml6jy",
[]string{
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
5,
0,
},
}
for i, s := range scenarios {
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
err := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
for _, s := range scenarios {
record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
if len(failed) != s.expectExpandFailures {
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(record)
encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, "@expand")
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps)
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
+427 -128
View File
@@ -1,9 +1,14 @@
package daos_test
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
@@ -16,7 +21,10 @@ func TestRecordQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
@@ -30,30 +38,50 @@ func TestFindRecordById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
id string
filter func(q *dbx.SelectQuery) error
expectError bool
collectionIdOrName string
id string
filter1 func(q *dbx.SelectQuery) error
filter2 func(q *dbx.SelectQuery) error
expectError bool
}{
{"00000000-bafd-48f7-b8b7-090638afe209", nil, true},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", nil, false},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
{"demo2", "missing", nil, nil, true},
{"missing", "0yxhwia2amd8gec", nil, nil, true},
{"demo2", "0yxhwia2amd8gec", nil, nil, false},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "missing"})
return nil
}, true},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
}, nil, true},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
return errors.New("test error")
}, nil, true},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "test3"})
return nil
}, nil, false},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "test3"})
return nil
}, func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"active": false})
return nil
}, true},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "lorem"})
{"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "test3"})
return nil
}, func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"active": true})
return nil
}, false},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindRecordById(collection, scenario.id, scenario.filter)
record, err := app.Dao().FindRecordById(
scenario.collectionIdOrName,
scenario.id,
scenario.filter1,
scenario.filter2,
)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -70,25 +98,34 @@ func TestFindRecordsByIds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
ids []string
filter func(q *dbx.SelectQuery) error
expectTotal int
expectError bool
collectionIdOrName string
ids []string
filter1 func(q *dbx.SelectQuery) error
filter2 func(q *dbx.SelectQuery) error
expectTotal int
expectError bool
}{
{[]string{}, nil, 0, false},
{[]string{"00000000-bafd-48f7-b8b7-090638afe209"}, nil, 0, false},
{[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209"}, nil, 1, false},
{"demo2", []string{}, nil, nil, 0, false},
{"demo2", []string{""}, nil, nil, 0, false},
{"demo2", []string{"missing"}, nil, nil, 0, false},
{"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true},
{"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
{"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
{
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
"demo2",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
nil,
nil,
2,
false,
},
{
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
"demo2",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
func(q *dbx.SelectQuery) error {
return nil // empty filter
},
func(q *dbx.SelectQuery) error {
return errors.New("test error")
},
@@ -96,9 +133,25 @@ func TestFindRecordsByIds(t *testing.T) {
true,
},
{
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
"demo2",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.Like("title", "test").Match(true, true))
q.AndWhere(dbx.HashExp{"active": true})
return nil
},
nil,
1,
false,
},
{
"sz5l5z67tg7gku0",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"active": true})
return nil
},
func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
return nil
},
1,
@@ -107,7 +160,12 @@ func TestFindRecordsByIds(t *testing.T) {
}
for i, scenario := range scenarios {
records, err := app.Dao().FindRecordsByIds(collection, scenario.ids, scenario.filter)
records, err := app.Dao().FindRecordsByIds(
scenario.collectionIdOrName,
scenario.ids,
scenario.filter1,
scenario.filter2,
)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -131,35 +189,53 @@ func TestFindRecordsByExpr(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
expression dbx.Expression
expectIds []string
expectError bool
collectionIdOrName string
expressions []dbx.Expression
expectIds []string
expectError bool
}{
{
"missing",
nil,
[]string{},
true,
},
{
dbx.HashExp{"id": 123},
"demo2",
nil,
[]string{
"achvryl401bhse3",
"llvuca81nly1qls",
"0yxhwia2amd8gec",
},
false,
},
{
"demo2",
[]dbx.Expression{
nil,
dbx.HashExp{"id": "123"},
},
[]string{},
false,
},
{
dbx.Like("title", "test").Match(true, true),
"sz5l5z67tg7gku0",
[]dbx.Expression{
dbx.Like("title", "test").Match(true, true),
dbx.HashExp{"active": true},
},
[]string{
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"577bd676-aacb-4072-b7da-99d00ee210a4",
"achvryl401bhse3",
"0yxhwia2amd8gec",
},
false,
},
}
for i, scenario := range scenarios {
records, err := app.Dao().FindRecordsByExpr(collection, scenario.expression)
records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -183,42 +259,52 @@ func TestFindFirstRecordByData(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
key string
value any
expectId string
expectError bool
collectionIdOrName string
key string
value any
expectId string
expectError bool
}{
{
"missing",
"id",
"llvuca81nly1qls",
"llvuca81nly1qls",
true,
},
{
"demo2",
"",
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"llvuca81nly1qls",
"",
true,
},
{
"demo2",
"id",
"invalid",
"",
true,
},
{
"demo2",
"id",
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"llvuca81nly1qls",
"llvuca81nly1qls",
false,
},
{
"sz5l5z67tg7gku0",
"title",
"lorem",
"b5c2ffc2-bafd-48f7-b8b7-090638afe209",
"test3",
"0yxhwia2amd8gec",
false,
},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindFirstRecordByData(collection, scenario.key, scenario.value)
record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -236,32 +322,44 @@ func TestIsRecordValueUnique(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
testManyRelsId1 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125"
testManyRelsId2 := "b84cd893-7119-43c9-8505-3c4e22da28a9"
testManyRelsId1 := "bgs820n361vj1qd"
testManyRelsId2 := "4q1xlclmfloku33"
testManyRelsId3 := "oap640cot4yru2s"
scenarios := []struct {
key string
value any
excludeId string
expected bool
collectionIdOrName string
key string
value any
excludeIds []string
expected bool
}{
{"", "", "", false},
{"missing", "unique", "", false},
{"title", "unique", "", true},
{"title", "demo1", "", false},
{"title", "demo1", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", true},
{"manyrels", []string{testManyRelsId2}, "", false},
{"manyrels", []any{testManyRelsId2}, "", false},
// with exclude
{"manyrels", []string{testManyRelsId1, testManyRelsId2}, "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", true},
// reverse order
{"manyrels", []string{testManyRelsId2, testManyRelsId1}, "", true},
{"demo2", "", "", nil, false},
{"demo2", "", "", []string{""}, false},
{"demo2", "missing", "unique", nil, false},
{"demo2", "title", "unique", nil, true},
{"demo2", "title", "unique", []string{}, true},
{"demo2", "title", "unique", []string{""}, true},
{"demo2", "title", "test1", []string{""}, false},
{"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true},
{"demo1", "rel_many", []string{testManyRelsId3}, nil, false},
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false},
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true},
// mixed json array order
{"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true},
// username special case-insensitive match
{"users", "username", "test2_username", nil, false},
{"users", "username", "TEST2_USERNAME", nil, false},
{"users", "username", "new_username", nil, true},
{"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true},
}
for i, scenario := range scenarios {
result := app.Dao().IsRecordValueUnique(collection, scenario.key, scenario.value, scenario.excludeId)
result := app.Dao().IsRecordValueUnique(
scenario.collectionIdOrName,
scenario.key,
scenario.value,
scenario.excludeIds...,
)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
@@ -269,43 +367,164 @@ func TestIsRecordValueUnique(t *testing.T) {
}
}
func TestFindUserRelatedRecords(t *testing.T) {
func TestFindAuthRecordByToken(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
u0 := &models.User{}
u1, _ := app.Dao().FindUserByEmail("test3@example.com")
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
scenarios := []struct {
user *models.User
expectedIds []string
token string
baseKey string
expectedEmail string
expectError bool
}{
{u0, []string{}},
{u1, []string{
"94568ca2-0bee-49d7-b749-06cb97956fd9", // demo2
"fc69274d-ca5c-416a-b9ef-561b101cfbb1", // profile
}},
{u2, []string{
"b2d5e39d-f569-4cc1-b593-3f074ad026bf", // profile
}},
// invalid auth token
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw",
app.Settings().RecordAuthToken.Secret,
"",
true,
},
// expired token
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
app.Settings().RecordAuthToken.Secret,
"",
true,
},
// wrong base key (password reset token secret instead of auth secret)
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
app.Settings().RecordPasswordResetToken.Secret,
"",
true,
},
// valid token and base key but with deleted/missing collection
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU",
app.Settings().RecordAuthToken.Secret,
"test@example.com",
true,
},
// valid token
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
app.Settings().RecordAuthToken.Secret,
"test@example.com",
false,
},
}
for i, scenario := range scenarios {
records, err := app.Dao().FindUserRelatedRecords(scenario.user)
if err != nil {
t.Fatal(err)
}
record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey)
if len(records) != len(scenario.expectedIds) {
t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
for _, r := range records {
if !list.ExistInSlice(r.Id, scenario.expectedIds) {
t.Errorf("(%d) Couldn't find %s in %v", i, r.Id, scenario.expectedIds)
}
if !scenario.expectError && record.Email() != scenario.expectedEmail {
t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email())
}
}
}
func TestFindAuthRecordByEmail(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
email string
expectError bool
}{
{"missing", "test@example.com", true},
{"demo2", "test@example.com", true},
{"users", "missing@example.com", true},
{"users", "test@example.com", false},
{"clients", "test2@example.com", false},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, scenario.email)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && record.Email() != scenario.email {
t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email())
}
}
}
func TestFindAuthRecordByUsername(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
username string
expectError bool
}{
{"missing", "test_username", true},
{"demo2", "test_username", true},
{"users", "missing", true},
{"users", "test2_username", false},
{"users", "TEST2_USERNAME", false}, // case insensitive check
{"clients", "clients43362", false},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && !strings.EqualFold(record.Username(), scenario.username) {
t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username())
}
}
}
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
baseUsername string
expectedPattern string
}{
// missing collection
{"missing", "test2_username", `^test2_username\d{12}$`},
// not an auth collection
{"demo2", "test2_username", `^test2_username\d{12}$`},
// auth collection with unique base username
{"users", "new_username", `^new_username$`},
{"users", "NEW_USERNAME", `^NEW_USERNAME$`},
// auth collection with existing username
{"users", "test2_username", `^test2_username\d{3}$`},
{"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`},
}
for i, scenario := range scenarios {
username := app.Dao().SuggestUniqueAuthRecordUsername(
scenario.collectionIdOrName,
scenario.baseUsername,
)
pattern, err := regexp.Compile(scenario.expectedPattern)
if err != nil {
t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err)
}
if !pattern.MatchString(username) {
t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username)
}
}
}
@@ -314,32 +533,64 @@ func TestSaveRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
// create
// ---
r1 := models.NewRecord(collection)
r1.SetDataValue("title", "test_new")
r1.Set("title", "test_new")
err1 := app.Dao().SaveRecord(r1)
if err1 != nil {
t.Fatal(err1)
}
newR1, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_new")
if newR1 == nil || newR1.Id != r1.Id || newR1.GetStringDataValue("title") != r1.GetStringDataValue("title") {
t.Errorf("Expected to find record %v, got %v", r1, newR1)
newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new")
if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") {
t.Fatalf("Expected to find record %v, got %v", r1, newR1)
}
// update
// ---
r2, _ := app.Dao().FindFirstRecordByData(collection, "id", "b5c2ffc2-bafd-48f7-b8b7-090638afe209")
r2.SetDataValue("title", "test_update")
r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec")
r2.Set("title", "test_update")
err2 := app.Dao().SaveRecord(r2)
if err2 != nil {
t.Fatal(err2)
}
newR2, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_update")
if newR2 == nil || newR2.Id != r2.Id || newR2.GetStringDataValue("title") != r2.GetStringDataValue("title") {
t.Errorf("Expected to find record %v, got %v", r2, newR2)
newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update")
if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") {
t.Fatalf("Expected to find record %v, got %v", r2, newR2)
}
}
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin")
// base collection test
r1 := models.NewRecord(baseCollection)
r1.Set("title", "test_new")
r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
r1.MarkAsNew()
if err := app.Dao().SaveRecord(r1); err != nil {
t.Fatalf("Expected nil, got error %v", err)
}
// auth collection test
r2 := models.NewRecord(authCollection)
r2.Set("username", "test_new")
r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
r2.MarkAsNew()
if err := app.Dao().SaveRecord(r2); err == nil {
t.Fatal("Expected error, got nil")
}
// try again with unique id
r2.Set("id", "unique_id")
if err := app.Dao().SaveRecord(r2); err != nil {
t.Fatalf("Expected nil, got error %v", err)
}
}
@@ -347,60 +598,87 @@ func TestDeleteRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
demo, _ := app.Dao().FindCollectionByNameOrId("demo")
demo2, _ := app.Dao().FindCollectionByNameOrId("demo2")
demoCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
// delete unsaved record
// ---
rec1 := models.NewRecord(demo)
err1 := app.Dao().DeleteRecord(rec1)
if err1 == nil {
t.Fatal("(rec1) Didn't expect to succeed deleting new record")
rec0 := models.NewRecord(demoCollection)
if err := app.Dao().DeleteRecord(rec0); err == nil {
t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record")
}
// delete existing record + external auths
// ---
rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err := app.Dao().DeleteRecord(rec1); err != nil {
t.Fatalf("(rec1) Expected nil, got error %v", err)
}
// check if it was really deleted
if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
}
// check if the external auths were deleted
if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
}
// delete existing record while being part of a non-cascade required relation
// ---
rec2, _ := app.Dao().FindFirstRecordByData(demo, "id", "848a1dea-5ddd-42d6-a00d-030547bffcfe")
err2 := app.Dao().DeleteRecord(rec2)
if err2 == nil {
rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
if err := app.Dao().DeleteRecord(rec2); err == nil {
t.Fatalf("(rec2) Expected error, got nil")
}
// delete existing record
// delete existing record + cascade
// ---
rec3, _ := app.Dao().FindFirstRecordByData(demo, "id", "577bd676-aacb-4072-b7da-99d00ee210a4")
err3 := app.Dao().DeleteRecord(rec3)
if err3 != nil {
t.Fatalf("(rec3) Expected nil, got error %v", err3)
calledQueries := []string{}
app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
// delete
if err := app.Dao().DeleteRecord(rec3); err != nil {
t.Fatalf("(rec3) Expected nil, got error %v", err)
}
// check if it was really deleted
rec3, _ = app.Dao().FindRecordById(demo, rec3.Id, nil)
rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id)
if rec3 != nil {
t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3)
}
// check if the operation cascaded
rel, _ := app.Dao().FindFirstRecordByData(demo2, "id", "63c2ab80-84ab-4057-a592-4604a731f78f")
rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
if rel != nil {
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
}
expectedRelOneJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
}
}
func TestSyncRecordTableSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo")
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
updatedCollection.Name = "demo_renamed"
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id)
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
updatedCollection.Schema.AddField(
&schema.SchemaField{
Name: "new_field",
@@ -421,6 +699,7 @@ func TestSyncRecordTableSchema(t *testing.T) {
expectedTableName string
expectedColumns []string
}{
// new base collection
{
&models.Collection{
Name: "new_table",
@@ -435,12 +714,32 @@ func TestSyncRecordTableSchema(t *testing.T) {
"new_table",
[]string{"id", "created", "updated", "test"},
},
// new auth collection
{
&models.Collection{
Name: "new_table_auth",
Type: models.CollectionTypeAuth,
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "test",
Type: schema.FieldTypeText,
},
),
},
nil,
"new_table_auth",
[]string{
"id", "created", "updated", "test",
"username", "email", "verified", "emailVisibility",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
},
},
// no changes
{
oldCollection,
oldCollection,
"demo",
[]string{"id", "created", "updated", "title", "file"},
"demo3",
[]string{"id", "created", "updated", "title", "active"},
},
// renamed table, deleted column, renamed columnd and new column
{
+5 -5
View File
@@ -59,7 +59,7 @@ func TestRequestsStats(t *testing.T) {
tests.MockRequestLogsData(app)
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
now := time.Now().UTC().Format(types.DefaultDateLayout)
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
@@ -84,10 +84,10 @@ func TestDeleteOldRequests(t *testing.T) {
date string
expectedTotal int
}{
{"2022-01-01 10:00:00.000", 2}, // no requests to delete before that time
{"2022-05-01 11:00:00.000", 1}, // only 1 request should have left
{"2022-05-03 11:00:00.000", 0}, // no more requests should have left
{"2022-05-04 11:00:00.000", 0}, // no more requests should have left
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
}
for i, scenario := range scenarios {
+63
View File
@@ -0,0 +1,63 @@
package daos
import (
"encoding/json"
"errors"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/security"
)
// FindSettings returns and decode the serialized app settings param value.
//
// The method will first try to decode the param value without decryption.
// If it fails and optEncryptionKey is set, it will try again by first
// decrypting the value and then decode it again.
//
// Returns an error if it fails to decode the stored serialized param value.
func (dao *Dao) FindSettings(optEncryptionKey ...string) (*settings.Settings, error) {
param, err := dao.FindParamByKey(models.ParamAppSettings)
if err != nil {
return nil, err
}
result := settings.New()
// try first without decryption
plainDecodeErr := json.Unmarshal(param.Value, result)
// failed, try to decrypt
if plainDecodeErr != nil {
var encryptionKey string
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
encryptionKey = optEncryptionKey[0]
}
// load without decrypt has failed and there is no encryption key to use for decrypt
if encryptionKey == "" {
return nil, errors.New("failed to load the stored app settings - missing or invalid encryption key")
}
// decrypt
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
if decryptErr != nil {
return nil, decryptErr
}
// decode again
decryptedDecodeErr := json.Unmarshal(decrypted, result)
if decryptedDecodeErr != nil {
return nil, decryptedDecodeErr
}
}
return result, nil
}
// SaveSettings persists the specified settings configuration.
//
// If optEncryptionKey is set, then the stored serialized value will be encrypted with it.
func (dao *Dao) SaveSettings(newSettings *settings.Settings, optEncryptionKey ...string) error {
return dao.SaveParam(models.ParamAppSettings, newSettings, optEncryptionKey...)
}
+50
View File
@@ -0,0 +1,50 @@
package daos_test
import (
"testing"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
func TestSaveAndFindSettings(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
encryptionKey := security.PseudorandomString(32)
// change unencrypted app settings
app.Settings().Meta.AppName = "save_unencrypted"
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
t.Fatal(err)
}
// check if the change was persisted
s1, err := app.Dao().FindSettings()
if err != nil {
t.Fatalf("Failed to fetch settings: %v", err)
}
if s1.Meta.AppName != "save_unencrypted" {
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_unencrypted", s1)
}
// make another change but this time provide an encryption key
app.Settings().Meta.AppName = "save_encrypted"
if err := app.Dao().SaveSettings(app.Settings(), encryptionKey); err != nil {
t.Fatal(err)
}
// try to fetch the settings without encryption key (should fail)
if s2, err := app.Dao().FindSettings(); err == nil {
t.Fatalf("Expected FindSettings to fail without an encryption key, got \n%v", s2)
}
// try again but this time with an encryption key
s3, err := app.Dao().FindSettings(encryptionKey)
if err != nil {
t.Fatalf("Failed to fetch settings with an encryption key %s: %v", encryptionKey, err)
}
if s3.Meta.AppName != "save_encrypted" {
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_encrypted", s3)
}
}
+8
View File
@@ -35,3 +35,11 @@ func (dao *Dao) DeleteTable(tableName string) error {
return err
}
// Vacuum executes VACUUM on the current dao.DB() instance in order to
// reclaim unused db disk space.
func (dao *Dao) Vacuum() error {
_, err := dao.DB().NewQuery("VACUUM").Execute()
return err
}
+28
View File
@@ -1,7 +1,10 @@
package daos_test
import (
"context"
"database/sql"
"testing"
"time"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
@@ -79,3 +82,28 @@ func TestDeleteTable(t *testing.T) {
}
}
}
func TestVacuum(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
calledQueries := []string{}
app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
if err := app.Dao().Vacuum(); err != nil {
t.Fatal(err)
}
if total := len(calledQueries); total != 1 {
t.Fatalf("Expected 1 query, got %d", total)
}
if calledQueries[0] != "VACUUM" {
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
}
}
-281
View File
@@ -1,281 +0,0 @@
package daos
import (
"database/sql"
"errors"
"fmt"
"log"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
// UserQuery returns a new User model select query.
func (dao *Dao) UserQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.User{})
}
// LoadProfile loads the profile record associated to the provided user.
func (dao *Dao) LoadProfile(user *models.User) error {
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
if err != nil {
return err
}
profile, err := dao.FindFirstRecordByData(collection, models.ProfileCollectionUserFieldName, user.Id)
if err != nil && err != sql.ErrNoRows {
return err
}
user.Profile = profile
return nil
}
// LoadProfiles loads the profile records associated to the provied users list.
func (dao *Dao) LoadProfiles(users []*models.User) error {
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
if err != nil {
return err
}
// extract user ids
ids := []string{}
usersMap := map[string]*models.User{}
for _, user := range users {
ids = append(ids, user.Id)
usersMap[user.Id] = user
}
profiles, err := dao.FindRecordsByExpr(collection, dbx.HashExp{
models.ProfileCollectionUserFieldName: list.ToInterfaceSlice(ids),
})
if err != nil {
return err
}
// populate each user.Profile member
for _, profile := range profiles {
userId := profile.GetStringDataValue(models.ProfileCollectionUserFieldName)
user, ok := usersMap[userId]
if !ok {
continue
}
user.Profile = profile
}
return nil
}
// FindUserById finds a single User model by its id.
//
// This method also auto loads the related user profile record
// into the found model.
func (dao *Dao) FindUserById(id string) (*models.User, error) {
model := &models.User{}
err := dao.UserQuery().
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
// try to load the user profile (if exist)
if err := dao.LoadProfile(model); err != nil {
log.Println(err)
}
return model, nil
}
// FindUserByEmail finds a single User model by its email address.
//
// This method also auto loads the related user profile record
// into the found model.
func (dao *Dao) FindUserByEmail(email string) (*models.User, error) {
model := &models.User{}
err := dao.UserQuery().
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
// try to load the user profile (if exist)
if err := dao.LoadProfile(model); err != nil {
log.Println(err)
}
return model, nil
}
// FindUserByToken finds the user associated with the provided JWT token.
// Returns an error if the JWT token is invalid or expired.
//
// This method also auto loads the related user profile record
// into the found model.
func (dao *Dao) FindUserByToken(token string, baseTokenKey string) (*models.User, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
}
// check required claims
id, _ := unverifiedClaims["id"].(string)
if id == "" {
return nil, errors.New("Missing or invalid token claims.")
}
user, err := dao.FindUserById(id)
if err != nil || user == nil {
return nil, err
}
verificationKey := user.TokenKey + baseTokenKey
// verify token signature
if _, err := security.ParseJWT(token, verificationKey); err != nil {
return nil, err
}
return user, nil
}
// IsUserEmailUnique checks if the provided email address is not
// already in use by other users.
func (dao *Dao) IsUserEmailUnique(email string, excludeId string) bool {
if email == "" {
return false
}
var exists bool
err := dao.UserQuery().
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
Row(&exists)
return err == nil && !exists
}
// DeleteUser deletes the provided User model.
//
// This method will also cascade the delete operation to all
// Record models that references the provided User model
// (delete or set to NULL, depending on the related user shema field settings).
//
// The delete operation may fail if the user is part of a required
// reference in another Record model (aka. cannot be deleted or set to NULL).
func (dao *Dao) DeleteUser(user *models.User) error {
// fetch related records
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
relatedRecords, err := dao.FindUserRelatedRecords(user)
if err != nil {
return err
}
return dao.RunInTransaction(func(txDao *Dao) error {
// check if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the user related fields (if they are not required)
// -----------------------------------------------------------
recordsLoop:
for _, record := range relatedRecords {
var needSave bool
for _, field := range record.Collection().Schema.Fields() {
if field.Type != schema.FieldTypeUser {
continue // not a user field
}
ids := record.GetStringSliceDataValue(field.Name)
// unset the user id
for i := len(ids) - 1; i >= 0; i-- {
if ids[i] == user.Id {
ids = append(ids[:i], ids[i+1:]...)
break
}
}
options, _ := field.Options.(*schema.UserOptions)
// cascade delete
// (only if there are no other user references in case of multiple select)
if options.CascadeDelete && len(ids) == 0 {
if err := txDao.DeleteRecord(record); err != nil {
return err
}
// no need to further iterate the user fields (the record is deleted)
continue recordsLoop
}
if field.Required && len(ids) == 0 {
return fmt.Errorf("Failed delete the user because a record exist with required user reference to the current model (%q, %q).", record.Id, record.Collection().Name)
}
// apply the reference changes
record.SetDataValue(field.Name, field.PrepareValue(ids))
needSave = true
}
if needSave {
if err := txDao.SaveRecord(record); err != nil {
return err
}
}
}
// -----------------------------------------------------------
return txDao.Delete(user)
})
}
// SaveUser upserts the provided User model.
//
// An empty profile record will be created if the user
// doesn't have a profile record set yet.
func (dao *Dao) SaveUser(user *models.User) error {
profileCollection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
if err != nil {
return err
}
// fetch the related user profile record (if exist)
var userProfile *models.Record
if user.HasId() {
userProfile, _ = dao.FindFirstRecordByData(
profileCollection,
models.ProfileCollectionUserFieldName,
user.Id,
)
}
return dao.RunInTransaction(func(txDao *Dao) error {
if err := txDao.Save(user); err != nil {
return err
}
// create default/empty profile record if doesn't exist
if userProfile == nil {
userProfile = models.NewRecord(profileCollection)
userProfile.SetDataValue(models.ProfileCollectionUserFieldName, user.Id)
if err := txDao.Save(userProfile); err != nil {
return err
}
user.Profile = userProfile
}
return nil
})
}
-274
View File
@@ -1,274 +0,0 @@
package daos_test
import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestUserQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
expected := "SELECT {{_users}}.* FROM `_users`"
sql := app.Dao().UserQuery().Build().SQL()
if sql != expected {
t.Errorf("Expected sql %s, got %s", expected, sql)
}
}
func TestLoadProfile(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// try to load missing profile (shouldn't return an error)
// ---
newUser := &models.User{}
err1 := app.Dao().LoadProfile(newUser)
if err1 != nil {
t.Fatalf("Expected nil, got error %v", err1)
}
// try to load existing profile
// ---
existingUser, _ := app.Dao().FindUserByEmail("test@example.com")
existingUser.Profile = nil // reset
err2 := app.Dao().LoadProfile(existingUser)
if err2 != nil {
t.Fatal(err2)
}
if existingUser.Profile == nil {
t.Fatal("Expected user profile to be loaded, got nil")
}
if existingUser.Profile.GetStringDataValue("name") != "test" {
t.Fatalf("Expected profile.name to be 'test', got %s", existingUser.Profile.GetStringDataValue("name"))
}
}
func TestLoadProfiles(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
u0 := &models.User{}
u1, _ := app.Dao().FindUserByEmail("test@example.com")
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
users := []*models.User{u0, u1, u2}
err := app.Dao().LoadProfiles(users)
if err != nil {
t.Fatal(err)
}
if u0.Profile != nil {
t.Errorf("Expected profile to be nil for u0, got %v", u0.Profile)
}
if u1.Profile == nil {
t.Errorf("Expected profile to be set for u1, got nil")
}
if u2.Profile == nil {
t.Errorf("Expected profile to be set for u2, got nil")
}
}
func TestFindUserById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
id string
expectError bool
}{
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", false},
}
for i, scenario := range scenarios {
user, err := app.Dao().FindUserById(scenario.id)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if user != nil && user.Id != scenario.id {
t.Errorf("(%d) Expected user with id %s, got %s", i, scenario.id, user.Id)
}
}
}
func TestFindUserByEmail(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
email string
expectError bool
}{
{"invalid", true},
{"missing@example.com", true},
{"test@example.com", false},
}
for i, scenario := range scenarios {
user, err := app.Dao().FindUserByEmail(scenario.email)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && user.Email != scenario.email {
t.Errorf("(%d) Expected user with email %s, got %s", i, scenario.email, user.Email)
}
}
}
func TestFindUserByToken(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
token string
baseKey string
expectedEmail string
expectError bool
}{
// invalid base key (password reset key for auth token)
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
app.Settings().UserPasswordResetToken.Secret,
"",
true,
},
// expired token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.RrSG5NwysI38DEZrIQiz3lUgI6sEuYGTll_jLRbBSiw",
app.Settings().UserAuthToken.Secret,
"",
true,
},
// valid token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
app.Settings().UserAuthToken.Secret,
"test@example.com",
false,
},
}
for i, scenario := range scenarios {
user, err := app.Dao().FindUserByToken(scenario.token, scenario.baseKey)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && user.Email != scenario.expectedEmail {
t.Errorf("(%d) Expected user model %s, got %s", i, scenario.expectedEmail, user.Email)
}
}
}
func TestIsUserEmailUnique(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
email string
excludeId string
expected bool
}{
{"", "", false},
{"test@example.com", "", false},
{"new@example.com", "", true},
{"test@example.com", "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", true},
}
for i, scenario := range scenarios {
result := app.Dao().IsUserEmailUnique(scenario.email, scenario.excludeId)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
}
}
}
func TestDeleteUser(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// try to delete unsaved user
// ---
err1 := app.Dao().DeleteUser(&models.User{})
if err1 == nil {
t.Fatal("Expected error, got nil")
}
// try to delete existing user
// ---
user, _ := app.Dao().FindUserByEmail("test3@example.com")
err2 := app.Dao().DeleteUser(user)
if err2 != nil {
t.Fatalf("Expected nil, got error %v", err2)
}
// check if the delete operation was cascaded to the profiles collection (record delete)
profilesCol, _ := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
profile, _ := app.Dao().FindRecordById(profilesCol, user.Profile.Id, nil)
if profile != nil {
t.Fatalf("Expected user profile to be deleted, got %v", profile)
}
// check if delete operation was cascaded to the related demo2 collection (null set)
demo2Col, _ := app.Dao().FindCollectionByNameOrId("demo2")
record, _ := app.Dao().FindRecordById(demo2Col, "94568ca2-0bee-49d7-b749-06cb97956fd9", nil)
if record == nil {
t.Fatal("Expected to found related record, got nil")
}
if record.GetStringDataValue("user") != "" {
t.Fatalf("Expected user field to be set to empty string, got %v", record.GetStringDataValue("user"))
}
}
func TestSaveUser(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create
// ---
u1 := &models.User{}
u1.Email = "new@example.com"
u1.SetPassword("123456")
err1 := app.Dao().SaveUser(u1)
if err1 != nil {
t.Fatal(err1)
}
u1, refreshErr1 := app.Dao().FindUserByEmail("new@example.com")
if refreshErr1 != nil {
t.Fatalf("Expected user with email new@example.com to have been created, got error %v", refreshErr1)
}
if u1.Profile == nil {
t.Fatalf("Expected creating a user to create also an empty profile record")
}
// update
// ---
u2, _ := app.Dao().FindUserByEmail("test@example.com")
u2.Email = "test_update@example.com"
err2 := app.Dao().SaveUser(u2)
if err2 != nil {
t.Fatal(err2)
}
u2, refreshErr2 := app.Dao().FindUserByEmail("test_update@example.com")
if u2 == nil {
t.Fatalf("Couldn't find user with email test_update@example.com (%v)", refreshErr2)
}
}
+6
View File
@@ -0,0 +1,6 @@
# ignore everything
/*
# exclude from the ignore filter
!.gitignore
!main.go
+69 -4
View File
@@ -2,21 +2,77 @@ package main
import (
"log"
"os"
"path/filepath"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
)
func main() {
app := pocketbase.New()
// ---------------------------------------------------------------
// Optional plugin flags:
// ---------------------------------------------------------------
var migrationsDir string
app.RootCmd.PersistentFlags().StringVar(
&migrationsDir,
"migrationsDir",
"",
"the directory with the user defined migrations",
)
var automigrate bool
app.RootCmd.PersistentFlags().BoolVar(
&automigrate,
"automigrate",
true,
"enable/disable auto migrations",
)
var publicDir string
app.RootCmd.PersistentFlags().StringVar(
&publicDir,
"publicDir",
defaultPublicDir(),
"the directory to serve static files",
)
var indexFallback bool
app.RootCmd.PersistentFlags().BoolVar(
&indexFallback,
"indexFallback",
true,
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
)
app.RootCmd.ParseFlags(os.Args[1:])
// ---------------------------------------------------------------
// Plugins and hooks:
// ---------------------------------------------------------------
// load js pb_migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
Dir: migrationsDir,
})
// migrate command (with js templates)
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// serves static files from the provided public dir (if exists)
subFs := echo.MustSubFS(e.Router.Filesystem, "pb_public")
e.Router.GET("/*", apis.StaticDirectoryHandler(subFs, false))
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
return nil
})
@@ -24,3 +80,12 @@ func main() {
log.Fatal(err)
}
}
// the default pb_public dir location is relative to the executable
func defaultPublicDir() string {
if strings.HasPrefix(os.Args[0], os.TempDir()) {
// most likely ran with go run
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}
+20 -6
View File
@@ -6,26 +6,40 @@ import (
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"
)
// AdminLogin defines an admin email/pass login form.
// AdminLogin is an admin email/pass login form.
type AdminLogin struct {
app core.App
dao *daos.Dao
Email string `form:"email" json:"email"`
Identity string `form:"identity" json:"identity"`
Password string `form:"password" json:"password"`
}
// NewAdminLogin creates new admin login form for the provided app.
// NewAdminLogin creates a new [AdminLogin] form initialized with
// the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewAdminLogin(app core.App) *AdminLogin {
return &AdminLogin{app: app}
return &AdminLogin{
app: app,
dao: app.Dao(),
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *AdminLogin) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminLogin) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.Email),
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat),
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
)
}
@@ -37,7 +51,7 @@ func (form *AdminLogin) Submit() (*models.Admin, error) {
return nil, err
}
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
admin, err := form.dao.FindAdminByEmail(form.Identity)
if err != nil {
return nil, err
}
+3 -34
View File
@@ -7,38 +7,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminLoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewAdminLogin(app)
scenarios := []struct {
email string
password string
expectError bool
}{
{"", "", true},
{"", "123", true},
{"test@example.com", "", true},
{"test", "123", true},
{"test@example.com", "123", false},
}
for i, s := range scenarios {
form.Email = s.email
form.Password = s.password
err := form.Validate()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
func TestAdminLoginSubmit(t *testing.T) {
func TestAdminLoginValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -52,14 +21,14 @@ func TestAdminLoginSubmit(t *testing.T) {
{"", "", true},
{"", "1234567890", true},
{"test@example.com", "", true},
{"test", "1234567890", true},
{"test", "test", true},
{"missing@example.com", "1234567890", true},
{"test@example.com", "123456789", true},
{"test@example.com", "1234567890", false},
}
for i, s := range scenarios {
form.Email = s.email
form.Identity = s.email
form.Password = s.password
admin, err := form.Submit()
+21 -9
View File
@@ -3,31 +3,46 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// AdminPasswordResetConfirm defines an admin password reset confirmation form.
// AdminPasswordResetConfirm is an admin password reset confirmation form.
type AdminPasswordResetConfirm struct {
app core.App
dao *daos.Dao
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewAdminPasswordResetConfirm creates new admin password reset confirmation form.
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
// form initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
return &AdminPasswordResetConfirm{
app: app,
dao: app.Dao(),
}
}
// SetDao replaces the form Dao instance with the provided one.
//
// This is useful if you want to use a specific transaction Dao instance
// instead of the default app.Dao().
func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminPasswordResetConfirm) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
validation.Field(&form.Password, validation.Required, validation.Length(10, 100)),
validation.Field(&form.Password, validation.Required, validation.Length(10, 72)),
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
)
}
@@ -38,10 +53,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
return nil // nothing to check
}
admin, err := form.app.Dao().FindAdminByToken(
v,
form.app.Settings().AdminPasswordResetToken.Secret,
)
admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret)
if err != nil || admin == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
}
@@ -56,7 +68,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
admin, err := form.app.Dao().FindAdminByToken(
admin, err := form.dao.FindAdminByToken(
form.Token,
form.app.Settings().AdminPasswordResetToken.Secret,
)
@@ -68,7 +80,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
if err := form.app.Dao().SaveAdmin(admin); err != nil {
if err := form.dao.SaveAdmin(admin); err != nil {
return nil, err
}
+10 -50
View File
@@ -8,7 +8,7 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -28,64 +28,23 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
{"test", "123", "123", true},
{
// expired
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
"1234567890",
"1234567890",
true,
},
{
// valid
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
"1234567890",
"1234567890",
false,
},
}
for i, s := range scenarios {
form.Token = s.token
form.Password = s.password
form.PasswordConfirm = s.passwordConfirm
err := form.Validate()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewAdminPasswordResetConfirm(app)
scenarios := []struct {
token string
password string
passwordConfirm string
expectError bool
}{
{"", "", "", true},
{"", "123", "", true},
{"", "", "123", true},
{"test", "", "", true},
{"test", "123", "", true},
{"test", "123", "123", true},
{
// expired
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"1234567890",
// valid with mismatched passwords
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"1234567890",
"1234567891",
true,
},
{
// valid
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
"1234567890",
"1234567890",
// valid with matching passwords
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"1234567891",
"1234567891",
false,
},
}
@@ -100,6 +59,7 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
}
if s.expectError {
+19 -7
View File
@@ -7,26 +7,38 @@ import (
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/mails"
"github.com/pocketbase/pocketbase/tools/types"
)
// AdminPasswordResetRequest defines an admin password reset request form.
// AdminPasswordResetRequest is an admin password reset request form.
type AdminPasswordResetRequest struct {
app core.App
resendThreshold float64
dao *daos.Dao
resendThreshold float64 // in seconds
Email string `form:"email" json:"email"`
}
// NewAdminPasswordResetRequest creates new admin password reset request form.
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
// form initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
return &AdminPasswordResetRequest{
app: app,
resendThreshold: 120, // 2 min
dao: app.Dao(),
resendThreshold: 120, // 2min
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
//
// This method doesn't verify that admin with `form.Email` exists (this is done on Submit).
@@ -36,7 +48,7 @@ func (form *AdminPasswordResetRequest) Validate() error {
&form.Email,
validation.Required,
validation.Length(1, 255),
is.Email,
is.EmailFormat,
),
)
}
@@ -48,7 +60,7 @@ func (form *AdminPasswordResetRequest) Submit() error {
return err
}
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
admin, err := form.dao.FindAdminByEmail(form.Email)
if err != nil {
return err
}
@@ -66,5 +78,5 @@ func (form *AdminPasswordResetRequest) Submit() error {
// update last sent timestamp
admin.LastResetSentAt = types.NowDateTime()
return form.app.Dao().SaveAdmin(admin)
return form.dao.SaveAdmin(admin)
}
+1 -30
View File
@@ -7,36 +7,7 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminPasswordResetRequestValidate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewAdminPasswordResetRequest(testApp)
scenarios := []struct {
email string
expectError bool
}{
{"", true},
{"", true},
{"invalid", true},
{"missing@example.com", false}, // doesn't check for existing admin
{"test@example.com", false},
}
for i, s := range scenarios {
form.Email = s.email
err := form.Validate()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
func TestAdminPasswordResetRequestSubmit(t *testing.T) {
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+47 -16
View File
@@ -4,41 +4,61 @@ import (
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/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// AdminUpsert defines an admin upsert (create/update) form.
// AdminUpsert is a [models.Admin] upsert (create/update) form.
type AdminUpsert struct {
app core.App
admin *models.Admin
isCreate bool
app core.App
dao *daos.Dao
admin *models.Admin
Id string `form:"id" json:"id"`
Avatar int `form:"avatar" json:"avatar"`
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewAdminUpsert creates new upsert form for the provided admin model
// (pass an empty admin model instance (`&models.Admin{}`) for create).
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
// config created from the provided [core.App] and [models.Admin] instances
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
form := &AdminUpsert{
app: app,
admin: admin,
isCreate: !admin.HasId(),
app: app,
dao: app.Dao(),
admin: admin,
}
// load defaults
form.Id = admin.Id
form.Avatar = admin.Avatar
form.Email = admin.Email
return form
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *AdminUpsert) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminUpsert) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.admin.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.admin.Id)),
),
validation.Field(
&form.Avatar,
validation.Min(0),
@@ -48,13 +68,13 @@ func (form *AdminUpsert) Validate() error {
&form.Email,
validation.Required,
validation.Length(1, 255),
is.Email,
is.EmailFormat,
validation.By(form.checkUniqueEmail),
),
validation.Field(
&form.Password,
validation.When(form.isCreate, validation.Required),
validation.Length(10, 100),
validation.When(form.admin.IsNew(), validation.Required),
validation.Length(10, 72),
),
validation.Field(
&form.PasswordConfirm,
@@ -67,19 +87,28 @@ func (form *AdminUpsert) Validate() error {
func (form *AdminUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string)
if form.app.Dao().IsAdminEmailUnique(v, form.admin.Id) {
if form.dao.IsAdminEmailUnique(v, form.admin.Id) {
return nil
}
return validation.NewError("validation_admin_email_exists", "Admin email already exists.")
}
// Submit validates the form and upserts the form's admin model.
func (form *AdminUpsert) Submit() error {
// Submit validates the form and upserts the form admin model.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
if err := form.Validate(); err != nil {
return err
}
// custom insertion id can be set only on create
if form.admin.IsNew() && form.Id != "" {
form.admin.MarkAsNew()
form.admin.SetId(form.Id)
}
form.admin.Avatar = form.Avatar
form.admin.Email = form.Email
@@ -87,5 +116,7 @@ func (form *AdminUpsert) Submit() error {
form.admin.SetPassword(form.Password)
}
return form.app.Dao().SaveAdmin(form.admin)
return runInterceptors(func() error {
return form.dao.SaveAdmin(form.admin)
}, interceptors...)
}
+176 -128
View File
@@ -2,9 +2,10 @@ package forms_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
@@ -29,125 +30,7 @@ func TestNewAdminUpsert(t *testing.T) {
}
}
func TestAdminUpsertValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
id string
avatar int
email string
password string
passwordConfirm string
expectedErrors int
}{
{
"",
-1,
"",
"",
"",
3,
},
{
"",
10,
"invalid",
"12345678",
"87654321",
4,
},
{
// existing email
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
3,
"test2@example.com",
"1234567890",
"1234567890",
1,
},
{
// mismatching passwords
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
3,
"test@example.com",
"1234567890",
"1234567891",
1,
},
{
// create without setting password
"",
9,
"test_create@example.com",
"",
"",
1,
},
{
// create with existing email
"",
9,
"test@example.com",
"1234567890!",
"1234567890!",
1,
},
{
// update without setting password
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
3,
"test_update@example.com",
"",
"",
0,
},
{
// create with password
"",
9,
"test_create@example.com",
"1234567890!",
"1234567890!",
0,
},
{
// update with password
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
4,
"test_update@example.com",
"1234567890",
"1234567890",
0,
},
}
for i, s := range scenarios {
admin := &models.Admin{}
if s.id != "" {
admin, _ = app.Dao().FindAdminById(s.id)
}
form := forms.NewAdminUpsert(app, admin)
form.Avatar = s.avatar
form.Email = s.email
form.Password = s.password
form.PasswordConfirm = s.passwordConfirm
result := form.Validate()
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%d) Failed to parse errors %v", i, result)
continue
}
if len(errs) != s.expectedErrors {
t.Errorf("(%d) Expected %d errors, got %d (%v)", i, s.expectedErrors, len(errs), errs)
}
}
}
func TestAdminUpsertSubmit(t *testing.T) {
func TestAdminUpsertValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -164,7 +47,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update empty
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
"sywbhecnh46rhm0",
`{}`,
false,
},
@@ -200,7 +83,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update failure - existing email
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
"sywbhecnh46rhm0",
`{
"email": "test2@example.com"
}`,
@@ -208,7 +91,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
},
{
// update failure - mismatching passwords
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
"sywbhecnh46rhm0",
`{
"password": "1234567890",
"passwordConfirm": "1234567891"
@@ -216,16 +99,16 @@ func TestAdminUpsertSubmit(t *testing.T) {
true,
},
{
// update succcess - new email
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
// update success - new email
"sywbhecnh46rhm0",
`{
"email": "test_update@example.com"
}`,
false,
},
{
// update succcess - new password
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
// update success - new password
"sywbhecnh46rhm0",
`{
"password": "1234567890",
"passwordConfirm": "1234567890"
@@ -252,7 +135,14 @@ func TestAdminUpsertSubmit(t *testing.T) {
continue
}
err := form.Submit()
interceptorCalls := 0
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptorCalls++
return next()
}
})
hasErr := err != nil
if hasErr != s.expectError {
@@ -266,6 +156,14 @@ func TestAdminUpsertSubmit(t *testing.T) {
continue
}
expectInterceptorCall := 1
if s.expectError {
expectInterceptorCall = 0
}
if interceptorCalls != expectInterceptorCall {
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
}
if s.expectError {
continue // skip persistence check
}
@@ -283,3 +181,153 @@ func TestAdminUpsertSubmit(t *testing.T) {
}
}
}
func TestAdminUpsertSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
admin := &models.Admin{}
form := forms.NewAdminUpsert(app, admin)
form.Email = "test_new@example.com"
form.Password = "1234567890"
form.PasswordConfirm = form.Password
testErr := errors.New("test_error")
interceptorAdminEmail := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1Called = true
return next()
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptorAdminEmail = admin.Email // to check if the record was filled
interceptor2Called = true
return testErr
}
}
err := form.Submit(interceptor1, interceptor2)
if err != testErr {
t.Fatalf("Expected error %v, got %v", testErr, err)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorAdminEmail != form.Email {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestAdminUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
jsonData string
collection *models.Admin
expectError bool
}{
{
"empty data",
"{}",
&models.Admin{},
false,
},
{
"empty id",
`{"id":""}`,
&models.Admin{},
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
&models.Admin{},
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
&models.Admin{},
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
&models.Admin{},
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
&models.Admin{},
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingAdmin,
true,
},
{
"using the same existing item id",
`{"id":"` + existingAdmin.Id + `"}`,
existingAdmin,
false,
},
{
"skipping the id for existing item",
`{}`,
existingAdmin,
false,
},
}
for i, scenario := range scenarios {
form := forms.NewAdminUpsert(app, scenario.collection)
if form.Email == "" {
form.Email = fmt.Sprintf("test_id_%d@example.com", i)
}
form.Password = "1234567890"
form.PasswordConfirm = form.Password
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindAdminById(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package models implements various services used for request data
// validation and applying changes to existing DB models through the app Dao.
package forms
import (
"regexp"
"github.com/pocketbase/pocketbase/models"
)
// base ID value regex pattern
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
// InterceptorNextFunc is a interceptor handler function.
// Usually used in combination with InterceptorFunc.
type InterceptorNextFunc = func() error
// InterceptorFunc defines a single interceptor function that
// will execute the provided next func handler.
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
// runInterceptors executes the provided list of interceptors.
func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error {
for i := len(interceptors) - 1; i >= 0; i-- {
next = interceptors[i](next)
}
return next()
}
// InterceptorWithRecordNextFunc is a Record interceptor handler function.
// Usually used in combination with InterceptorWithRecordFunc.
type InterceptorWithRecordNextFunc = func(record *models.Record) error
// InterceptorWithRecordFunc defines a single Record interceptor function
// that will execute the provided next func handler.
type InterceptorWithRecordFunc func(next InterceptorWithRecordNextFunc) InterceptorWithRecordNextFunc
// runInterceptorsWithRecord executes the provided list of Record interceptors.
func runInterceptorsWithRecord(record *models.Record, next InterceptorWithRecordNextFunc, interceptors ...InterceptorWithRecordFunc) error {
for i := len(interceptors) - 1; i >= 0; i-- {
next = interceptors[i](next)
}
return next(record)
}
+212 -46
View File
@@ -1,25 +1,32 @@
package forms
import (
"encoding/json"
"fmt"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"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/resolvers"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/types"
)
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
// CollectionUpsert defines a collection upsert (create/update) form.
// CollectionUpsert is a [models.Collection] upsert (create/update) form.
type CollectionUpsert struct {
app core.App
dao *daos.Dao
collection *models.Collection
isCreate bool
Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
@@ -28,27 +35,39 @@ type CollectionUpsert struct {
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
Options types.JsonMap `form:"options" json:"options"`
}
// NewCollectionUpsert creates new collection upsert form for the provided Collection model
// (pass an empty Collection model instance (`&models.Collection{}`) for create).
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
// config created from the provided [core.App] and [models.Collection] instances
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
form := &CollectionUpsert{
app: app,
dao: app.Dao(),
collection: collection,
isCreate: !collection.HasId(),
}
// load defaults
form.Name = collection.Name
form.System = collection.System
form.ListRule = collection.ListRule
form.ViewRule = collection.ViewRule
form.CreateRule = collection.CreateRule
form.UpdateRule = collection.UpdateRule
form.DeleteRule = collection.DeleteRule
form.Id = form.collection.Id
form.Type = form.collection.Type
form.Name = form.collection.Name
form.System = form.collection.System
form.ListRule = form.collection.ListRule
form.ViewRule = form.collection.ViewRule
form.CreateRule = form.collection.CreateRule
form.UpdateRule = form.collection.UpdateRule
form.DeleteRule = form.collection.DeleteRule
form.Options = form.collection.Options
clone, _ := collection.Schema.Clone()
if form.Type == "" {
form.Type = models.CollectionTypeBase
}
clone, _ := form.collection.Schema.Clone()
if clone != nil {
form.Schema = *clone
} else {
@@ -58,13 +77,34 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
return form
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error {
isAuth := form.Type == models.CollectionTypeAuth
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.collection.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.collection.Id)),
),
validation.Field(
&form.System,
validation.By(form.ensureNoSystemFlagChange),
),
validation.Field(
&form.Type,
validation.Required,
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
validation.By(form.ensureNoTypeChange),
),
validation.Field(
&form.Name,
validation.Required,
@@ -76,26 +116,39 @@ func (form *CollectionUpsert) Validate() error {
// validates using the type's own validation rules + some collection's specific
validation.Field(
&form.Schema,
validation.By(form.checkMinSchemaFields),
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureNoFieldsNameReuse),
validation.By(form.ensureExistingRelationCollectionId),
validation.When(
isAuth,
validation.By(form.ensureNoAuthFieldName),
),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
validation.Field(&form.Options, validation.By(form.checkOptions)),
)
}
func (form *CollectionUpsert) checkUniqueName(value any) error {
v, _ := value.(string)
if !form.app.Dao().IsCollectionNameUnique(v, form.collection.Id) {
// ensure unique collection name
if !form.dao.IsCollectionNameUnique(v, form.collection.Id) {
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
}
if (form.isCreate || !strings.EqualFold(v, form.collection.Name)) && form.app.Dao().HasTable(v) {
// ensure that the collection name doesn't collide with the id of any collection
if form.dao.FindById(&models.Collection{}, v) == nil {
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
}
// ensure that there is no existing table name with the same name
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
@@ -105,37 +158,117 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
v, _ := value.(string)
if form.isCreate || !form.collection.System || v == form.collection.Name {
return nil
if !form.collection.IsNew() && form.collection.System && v != form.collection.Name {
return validation.NewError("validation_collection_system_name_change", "System collections cannot be renamed.")
}
return validation.NewError("validation_system_collection_name_change", "System collections cannot be renamed.")
return nil
}
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
v, _ := value.(bool)
if form.isCreate || v == form.collection.System {
return nil
if !form.collection.IsNew() && v != form.collection.System {
return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
}
return validation.NewError("validation_system_collection_flag_change", "System collection state cannot be changed.")
return nil
}
func (form *CollectionUpsert) ensureNoTypeChange(value any) error {
v, _ := value.(string)
if !form.collection.IsNew() && v != form.collection.Type {
return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
}
return nil
}
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
v, _ := value.(schema.Schema)
for _, field := range v.Fields() {
for i, field := range v.Fields() {
oldField := form.collection.Schema.GetFieldById(field.Id)
if oldField != nil && oldField.Type != field.Type {
return validation.NewError("validation_field_type_change", "Field type cannot be changed.")
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_type_change",
"Field type cannot be changed.",
)}
}
}
return nil
}
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
v, _ := value.(schema.Schema)
for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation {
continue
}
options, _ := field.Options.(*schema.RelationOptions)
if options == nil {
continue
}
if _, err := form.dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
)}
}
}
return nil
}
func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
v, _ := value.(schema.Schema)
if form.Type != models.CollectionTypeAuth {
return nil // not an auth collection
}
authFieldNames := schema.AuthFieldNames()
// exclude the meta RecordUpsert form fields
authFieldNames = append(authFieldNames, "password", "passwordConfirm", "oldPassword")
errs := validation.Errors{}
for i, field := range v.Fields() {
if list.ExistInSlice(field.Name, authFieldNames) {
errs[fmt.Sprint(i)] = validation.Errors{
"name": validation.NewError(
"validation_reserved_auth_field_name",
"The field name is reserved and cannot be used.",
),
}
}
}
if len(errs) > 0 {
return errs
}
return nil
}
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
if form.Type == models.CollectionTypeAuth {
return nil // auth collections doesn't require having additional schema fields
}
v, ok := value.(schema.Schema)
if !ok || len(v.Fields()) == 0 {
return validation.ErrRequired
}
return nil
}
func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
v, _ := value.(schema.Schema)
@@ -154,33 +287,51 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
return nil
}
func (form *CollectionUpsert) ensureNoFieldsNameReuse(value any) error {
v, _ := value.(schema.Schema)
func (form *CollectionUpsert) checkRule(value any) error {
v, _ := value.(*string)
if v == nil || *v == "" {
return nil // nothing to check
}
for _, field := range v.Fields() {
oldField := form.collection.Schema.GetFieldByName(field.Name)
dummy := *form.collection
dummy.Type = form.Type
dummy.Schema = form.Schema
dummy.System = form.System
dummy.Options = form.Options
if oldField != nil && oldField.Id != field.Id {
return validation.NewError("validation_field_old_field_exist", "Cannot use existing schema field names when renaming fields.")
}
r := resolvers.NewRecordFieldResolver(form.dao, &dummy, nil, true)
_, err := search.FilterData(*v).BuildExpr(r)
if err != nil {
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
}
return nil
}
func (form *CollectionUpsert) checkRule(value any) error {
v, _ := value.(*string)
func (form *CollectionUpsert) checkOptions(value any) error {
v, _ := value.(types.JsonMap)
if v == nil || *v == "" {
return nil // nothing to check
}
if form.Type == models.CollectionTypeAuth {
raw, err := v.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
dummy := &models.Collection{Schema: form.Schema}
r := resolvers.NewRecordFieldResolver(form.app.Dao(), dummy, nil)
options := models.CollectionAuthOptions{}
if err := json.Unmarshal(raw, &options); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
_, err := search.FilterData(*v).BuildExpr(r)
if err != nil {
return validation.NewError("validation_collection_rule", "Invalid filter rule.")
// check the generic validations
if err := options.Validate(); err != nil {
return err
}
// additional form specific validations
if err := form.checkRule(options.ManageRule); err != nil {
return validation.Errors{"manageRule": err}
}
}
return nil
@@ -189,18 +340,30 @@ func (form *CollectionUpsert) checkRule(value any) error {
// Submit validates the form and upserts the form's Collection model.
//
// On success the related record table schema will be auto updated.
func (form *CollectionUpsert) Submit() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
if err := form.Validate(); err != nil {
return err
}
// system flag can be set only for create
if form.isCreate {
if form.collection.IsNew() {
// type can be set only on create
form.collection.Type = form.Type
// system flag can be set only on create
form.collection.System = form.System
// custom insertion id can be set only on create
if form.Id != "" {
form.collection.MarkAsNew()
form.collection.SetId(form.Id)
}
}
// system collections cannot be renamed
if form.isCreate || !form.collection.System {
if form.collection.IsNew() || !form.collection.System {
form.collection.Name = form.Name
}
@@ -210,6 +373,9 @@ func (form *CollectionUpsert) Submit() error {
form.collection.CreateRule = form.CreateRule
form.collection.UpdateRule = form.UpdateRule
form.collection.DeleteRule = form.DeleteRule
form.collection.SetOptions(form.Options)
return form.app.Dao().SaveCollection(form.collection)
return runInterceptors(func() error {
return form.dao.SaveCollection(form.collection)
}, interceptors...)
}
+279 -141
View File
@@ -2,6 +2,7 @@ package forms_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -9,6 +10,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
@@ -17,7 +19,8 @@ func TestNewCollectionUpsert(t *testing.T) {
defer app.Cleanup()
collection := &models.Collection{}
collection.Name = "test"
collection.Name = "test_name"
collection.Type = "test_type"
collection.System = true
listRule := "testview"
collection.ListRule = &listRule
@@ -40,6 +43,10 @@ func TestNewCollectionUpsert(t *testing.T) {
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
}
if form.Type != collection.Type {
t.Errorf("Expected Type %q, got %q", collection.Type, form.Type)
}
if form.System != collection.System {
t.Errorf("Expected System %v, got %v", collection.System, form.System)
}
@@ -79,95 +86,25 @@ func TestNewCollectionUpsert(t *testing.T) {
}
}
func TestCollectionUpsertValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
jsonData string
expectedErrors []string
}{
{"{}", []string{"name", "schema"}},
{
`{
"name": "test ?!@#$",
"system": true,
"schema": [
{"name":"","type":"text"}
],
"listRule": "missing = '123'",
"viewRule": "missing = '123'",
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'"
}`,
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
},
{
`{
"name": "test",
"system": true,
"schema": [
{"name":"test","type":"text"}
],
"listRule": "test='123'",
"viewRule": "test='123'",
"createRule": "test='123'",
"updateRule": "test='123'",
"deleteRule": "test='123'"
}`,
[]string{},
},
}
for i, s := range scenarios {
form := forms.NewCollectionUpsert(app, &models.Collection{})
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
continue
}
// parse errors
result := form.Validate()
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)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
}
}
}
}
func TestCollectionUpsertSubmit(t *testing.T) {
func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
testName string
existingName string
jsonData string
expectedErrors []string
}{
// empty create
{"", "{}", []string{"name", "schema"}},
// empty update
{"demo", "{}", []string{}},
// create failure
{"empty create (base)", "", "{}", []string{"name", "schema"}},
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
{"empty update", "demo2", "{}", []string{}},
{
"create failure",
"",
`{
"name": "test ?!@#$",
"type": "invalid",
"system": true,
"schema": [
{"name":"","type":"text"}
@@ -178,13 +115,13 @@ func TestCollectionUpsertSubmit(t *testing.T) {
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'"
}`,
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
},
// create failure - existing name
{
"create failure - existing name",
"",
`{
"name": "demo",
"name": "demo1",
"system": true,
"schema": [
{"name":"test","type":"text"}
@@ -197,19 +134,19 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"name"},
},
// create failure - existing internal table
{
"create failure - existing internal table",
"",
`{
"name": "_users",
"name": "_admins",
"schema": [
{"name":"test","type":"text"}
]
}`,
[]string{"name"},
},
// create failure - name starting with underscore
{
"create failure - name starting with underscore",
"",
`{
"name": "_test_new",
@@ -219,8 +156,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"name"},
},
// create failure - duplicated field names (case insensitive)
{
"create failure - duplicated field names (case insensitive)",
"",
`{
"name": "test_new",
@@ -231,26 +168,40 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
// create success
{
"create failure - check type options validators",
"",
`{
"name": "test_new",
"type": "auth",
"schema": [
{"name":"test","type":"text"}
],
"options": { "minPasswordLength": 3 }
}`,
[]string{"options"},
},
{
"create success",
"",
`{
"name": "test_new",
"type": "auth",
"system": true,
"schema": [
{"id":"a123456","name":"test1","type":"text"},
{"id":"b123456","name":"test2","type":"email"}
],
"listRule": "test1='123'",
"viewRule": "test1='123'",
"createRule": "test1='123'",
"updateRule": "test1='123'",
"deleteRule": "test1='123'"
"listRule": "test1='123' && verified = true",
"viewRule": "test1='123' && emailVisibility = true",
"createRule": "test1='123' && email != ''",
"updateRule": "test1='123' && username != ''",
"deleteRule": "test1='123' && id != ''"
}`,
[]string{},
},
// update failure - changing field type
{
"update failure - changing field type",
"test_new",
`{
"schema": [
@@ -260,8 +211,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
// update failure - rename fields to existing field names (aka. reusing field names)
{
"update success - rename fields to existing field names (aka. reusing field names)",
"test_new",
`{
"schema": [
@@ -269,36 +220,45 @@ func TestCollectionUpsertSubmit(t *testing.T) {
{"id":"b123456","name":"test1","type":"email"}
]
}`,
[]string{"schema"},
[]string{},
},
// update failure - existing name
{
"demo",
`{"name": "demo2"}`,
"update failure - existing name",
"demo2",
`{"name": "demo3"}`,
[]string{"name"},
},
// update failure - changing system collection
{
models.ProfileCollectionName,
"update failure - changing system collection",
"nologin",
`{
"name": "update",
"system": false,
"schema": [
{"id":"koih1lqx","name":"userId","type":"text"}
{"id":"koih1lqx","name":"abc","type":"text"}
],
"listRule": "userId = '123'",
"viewRule": "userId = '123'",
"createRule": "userId = '123'",
"updateRule": "userId = '123'",
"deleteRule": "userId = '123'"
"listRule": "abc = '123'",
"viewRule": "abc = '123'",
"createRule": "abc = '123'",
"updateRule": "abc = '123'",
"deleteRule": "abc = '123'"
}`,
[]string{"name", "system", "schema"},
[]string{"name", "system"},
},
// update failure - all fields
{
"demo",
"update failure - changing collection type",
"demo3",
`{
"type": "auth"
}`,
[]string{"type"},
},
{
"update failure - all fields",
"demo2",
`{
"name": "test ?!@#$",
"type": "invalid",
"system": true,
"schema": [
{"name":"","type":"text"}
@@ -307,29 +267,32 @@ func TestCollectionUpsertSubmit(t *testing.T) {
"viewRule": "missing = '123'",
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'"
"deleteRule": "missing = '123'",
"options": {"test": 123}
}`,
[]string{"name", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
},
// update success - update all fields
{
"demo",
"update success - update all fields",
"clients",
`{
"name": "demo_update",
"type": "auth",
"schema": [
{"id":"_2hlxbmp","name":"test","type":"text"}
],
"listRule": "test='123'",
"viewRule": "test='123'",
"createRule": "test='123'",
"updateRule": "test='123'",
"deleteRule": "test='123'"
"listRule": "test='123' && verified = true",
"viewRule": "test='123' && emailVisibility = true",
"createRule": "test='123' && email != ''",
"updateRule": "test='123' && username != ''",
"deleteRule": "test='123' && id != ''",
"options": {"minPasswordLength": 10}
}`,
[]string{},
},
// update failure - rename the schema field of the last updated collection
// (fail due to filters old field references)
{
"update failure - rename the schema field of the last updated collection",
"demo_update",
`{
"schema": [
@@ -338,9 +301,9 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
},
// update success - rename the schema field of the last updated collection
// (cleared filter references)
{
"update success - rename the schema field of the last updated collection",
"demo_update",
`{
"schema": [
@@ -354,21 +317,21 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{},
},
// update success - system collection
{
models.ProfileCollectionName,
"update success - system collection",
"nologin",
`{
"listRule": "userId='123'",
"viewRule": "userId='123'",
"createRule": "userId='123'",
"updateRule": "userId='123'",
"deleteRule": "userId='123'"
"listRule": "name='123'",
"viewRule": "name='123'",
"createRule": "name='123'",
"updateRule": "name='123'",
"deleteRule": "name='123'"
}`,
[]string{},
},
}
for i, s := range scenarios {
for _, s := range scenarios {
collection := &models.Collection{}
if s.existingName != "" {
var err error
@@ -383,25 +346,42 @@ func TestCollectionUpsertSubmit(t *testing.T) {
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
continue
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptorCalls++
return next()
}
}
// parse errors
result := form.Submit()
result := form.Submit(interceptor)
errs, ok := result.(validation.Errors)
if !ok && result != nil {
t.Errorf("(%d) Failed to parse errors %v", i, result)
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("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
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("(%d) Missing expected error key %q in %v", i, k, errs)
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
}
}
@@ -411,42 +391,200 @@ func TestCollectionUpsertSubmit(t *testing.T) {
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
if collection == nil {
t.Errorf("(%d) Expected to find collection %q, got nil", i, form.Name)
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
continue
}
if form.Name != collection.Name {
t.Errorf("(%d) Expected Name %q, got %q", i, collection.Name, form.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("(%d) Expected System %v, got %v", i, collection.System, form.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("(%d) Expected ListRule %v, got %v", i, collection.ListRule, form.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("(%d) Expected ViewRule %v, got %v", i, collection.ViewRule, form.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("(%d) Expected CreateRule %v, got %v", i, collection.CreateRule, form.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("(%d) Expected UpdateRule %v, got %v", i, collection.UpdateRule, form.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("(%d) Expected DeleteRule %v, got %v", i, collection.DeleteRule, form.DeleteRule)
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
}
formSchema, _ := form.Schema.MarshalJSON()
collectionSchema, _ := collection.Schema.MarshalJSON()
if string(formSchema) != string(collectionSchema) {
t.Errorf("(%d) Expected Schema %v, got %v", i, string(collectionSchema), string(formSchema))
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
}
}
}
func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
form := forms.NewCollectionUpsert(app, collection)
form.Name = "test_new"
testErr := errors.New("test_error")
interceptorCollectionName := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1Called = true
return next()
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptorCollectionName = collection.Name // to check if the record was filled
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorCollectionName != form.Name {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestCollectionUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
newCollection := func() *models.Collection {
return &models.Collection{
Name: "c_" + security.PseudorandomString(4),
Schema: existingCollection.Schema,
}
}
scenarios := []struct {
name string
jsonData string
collection *models.Collection
expectError bool
}{
{
"empty data",
"{}",
newCollection(),
false,
},
{
"empty id",
`{"id":""}`,
newCollection(),
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
newCollection(),
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
newCollection(),
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
newCollection(),
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
newCollection(),
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingCollection,
true,
},
{
"using the same existing item id",
`{"id":"` + existingCollection.Id + `"}`,
existingCollection,
false,
},
{
"skipping the id for existing item",
`{}`,
existingCollection,
false,
},
}
for _, s := range scenarios {
form := forms.NewCollectionUpsert(app, s.collection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindCollectionByNameOrId(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err)
}
}
}
}
+136
View File
@@ -0,0 +1,136 @@
package forms
import (
"encoding/json"
"fmt"
"log"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// CollectionsImport is a form model to bulk import
// (create, replace and delete) collections from a user provided list.
type CollectionsImport struct {
app core.App
dao *daos.Dao
Collections []*models.Collection `form:"collections" json:"collections"`
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
}
// NewCollectionsImport creates a new [CollectionsImport] form with
// initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewCollectionsImport(app core.App) *CollectionsImport {
return &CollectionsImport{
app: app,
dao: app.Dao(),
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *CollectionsImport) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionsImport) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Collections, validation.Required),
)
}
// Submit applies the import, aka.:
// - imports the form collections (create or replace)
// - sync the collection changes with their related records table
// - ensures the integrity of the imported structure (aka. run validations for each collection)
// - if [form.DeleteMissing] is set, deletes all local collections that are not found in the imports list
//
// All operations are wrapped in a single transaction that are
// rollbacked on the first encountered error.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(func() error {
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
importErr := txDao.ImportCollections(
form.Collections,
form.DeleteMissing,
form.beforeRecordsSync,
)
if importErr == nil {
return nil
}
// validation failure
if err, ok := importErr.(validation.Errors); ok {
return err
}
// generic/db failure
if form.app.IsDebug() {
log.Println("Internal import failure:", importErr)
}
return validation.Errors{"collections": validation.NewError(
"collections_import_failure",
"Failed to import the collections configuration.",
)}
})
}, interceptors...)
}
func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
// refresh the actual persisted collections list
refreshedCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&refreshedCollections); err != nil {
return err
}
// trigger the validator for each existing collection to
// ensure that the app is not left in a broken state
for _, collection := range refreshedCollections {
upsertModel := mappedOld[collection.GetId()]
if upsertModel == nil {
upsertModel = collection
}
upsertModel.MarkAsNotNew()
upsertForm := NewCollectionUpsert(form.app, upsertModel)
upsertForm.SetDao(txDao)
// load form fields with the refreshed collection state
upsertForm.Id = collection.Id
upsertForm.Type = collection.Type
upsertForm.Name = collection.Name
upsertForm.System = collection.System
upsertForm.ListRule = collection.ListRule
upsertForm.ViewRule = collection.ViewRule
upsertForm.CreateRule = collection.CreateRule
upsertForm.UpdateRule = collection.UpdateRule
upsertForm.DeleteRule = collection.DeleteRule
upsertForm.Schema = collection.Schema
upsertForm.Options = collection.Options
if err := upsertForm.Validate(); err != nil {
// serialize the validation error(s)
serializedErr, _ := json.MarshalIndent(err, "", " ")
return validation.Errors{"collections": validation.NewError(
"collections_import_validate_failure",
fmt.Sprintf("Data validations failed for collection %q (%s):\n%s", collection.Name, collection.Id, serializedErr),
)}
}
}
return nil
}
+434
View File
@@ -0,0 +1,434 @@
package forms_test
import (
"encoding/json"
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestCollectionsImportValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewCollectionsImport(app)
scenarios := []struct {
collections []*models.Collection
expectError bool
}{
{nil, true},
{[]*models.Collection{}, true},
{[]*models.Collection{{}}, false},
}
for i, s := range scenarios {
form.Collections = s.collections
err := form.Validate()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
func TestCollectionsImportSubmit(t *testing.T) {
scenarios := []struct {
name string
jsonData string
expectError bool
expectCollectionsCount int
expectEvents map[string]int
}{
{
name: "empty collections",
jsonData: `{
"deleteMissing": true,
"collections": []
}`,
expectError: true,
expectCollectionsCount: 7,
expectEvents: nil,
},
{
name: "one of the collections has invalid data",
jsonData: `{
"collections": [
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import 2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
},
{
name: "test empty base collection schema",
jsonData: `{
"collections": [
{
"name": "import1"
},
{
"name": "import2",
"type": "auth"
}
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
},
{
name: "all imported collections has valid data",
jsonData: `{
"collections": [
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import3",
"type": "auth"
}
]
}`,
expectError: false,
expectCollectionsCount: 10,
expectEvents: map[string]int{
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
},
},
{
name: "new collection with existing name",
jsonData: `{
"collections": [
{
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
},
{
name: "delete system + modified + new collection",
jsonData: `{
"deleteMissing": true,
"collections": [
{
"id":"sz5l5z67tg7gku0",
"name":"demo2",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeDelete": 5,
},
},
{
name: "modified + new collection",
jsonData: `{
"collections": [
{
"id":"sz5l5z67tg7gku0",
"name":"demo2",
"schema":[
{
"id":"_2hlxbmp",
"name":"title_new",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 9,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
},
},
{
name: "delete non-system + modified + new collection",
jsonData: `{
"deleteMissing": true,
"collections": [
{
"id": "kpv709sk2lqbqk8",
"system": true,
"name": "nologin",
"type": "auth",
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": [],
"manageRule": "@request.auth.collectionName = 'users'",
"minPasswordLength": 8,
"onlyEmailDomains": [],
"requireEmail": true
},
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"schema": [
{
"id": "x8zzktwe",
"name": "name",
"type": "text",
"system": false,
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
]
},
{
"id":"sz5l5z67tg7gku0",
"name":"demo2",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"id": "test_deleted_collection_name_reuse",
"name": "demo1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 3,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnModelBeforeDelete": 5,
"OnModelAfterDelete": 5,
},
},
}
for _, s := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewCollectionsImport(testApp)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != s.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
}
// check events
if len(testApp.EventCalls) > len(s.expectEvents) {
t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
}
for event, expectedCalls := range s.expectEvents {
actualCalls := testApp.EventCalls[event]
if actualCalls != expectedCalls {
t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
}
}
}
}
func TestCollectionsImportSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
form := forms.NewCollectionsImport(app)
form.Collections = collections
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1Called = true
return next()
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
)
// RealtimeSubscribe defines a RealtimeSubscribe request form.
// RealtimeSubscribe is a realtime subscriptions request form.
type RealtimeSubscribe struct {
ClientId string `form:"clientId" json:"clientId"`
Subscriptions []string `form:"subscriptions" json:"subscriptions"`
+142
View File
@@ -0,0 +1,142 @@
package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"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/security"
)
// RecordEmailChangeConfirm is an auth record email change confirmation form.
type RecordEmailChangeConfirm struct {
app core.App
dao *daos.Dao
collection *models.Collection
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
}
// NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form
// initialized with from the provided [core.App] and [models.Collection] instances.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordEmailChangeConfirm(app core.App, collection *models.Collection) *RecordEmailChangeConfirm {
return &RecordEmailChangeConfirm{
app: app,
dao: app.Dao(),
collection: collection,
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordEmailChangeConfirm) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordEmailChangeConfirm) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Token,
validation.Required,
validation.By(form.checkToken),
),
validation.Field(
&form.Password,
validation.Required,
validation.Length(1, 100),
validation.By(form.checkPassword),
),
)
}
func (form *RecordEmailChangeConfirm) checkToken(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
authRecord, _, err := form.parseToken(v)
if err != nil {
return err
}
if authRecord.Collection().Id != form.collection.Id {
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
}
return nil
}
func (form *RecordEmailChangeConfirm) checkPassword(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
authRecord, _, _ := form.parseToken(form.Token)
if authRecord == nil || !authRecord.ValidatePassword(v) {
return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.")
}
return nil
}
func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, string, error) {
// check token payload
claims, _ := security.ParseUnverifiedJWT(token)
newEmail, _ := claims["newEmail"].(string)
if newEmail == "" {
return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
}
// ensure that there aren't other users with the new email
if !form.dao.IsRecordValueUnique(form.collection.Id, schema.FieldNameEmail, newEmail) {
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
}
// verify that the token is not expired and its signature is valid
authRecord, err := form.dao.FindAuthRecordByToken(
token,
form.app.Settings().RecordEmailChangeToken.Secret,
)
if err != nil || authRecord == nil {
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
}
return authRecord, newEmail, nil
}
// Submit validates and submits the auth record email change confirmation form.
// On success returns the updated auth record associated to `form.Token`.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
authRecord, newEmail, err := form.parseToken(form.Token)
if err != nil {
return nil, err
}
authRecord.SetEmail(newEmail)
authRecord.SetVerified(true)
authRecord.RefreshTokenKey() // invalidate old tokens
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
return form.dao.SaveRecord(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return authRecord, nil
}
+200
View File
@@ -0,0 +1,200 @@
package forms_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
jsonData string
expectedErrors []string
}{
// empty payload
{"{}", []string{"token", "password"}},
// empty data
{
`{"token": "", "password": ""}`,
[]string{"token", "password"},
},
// invalid token payload
{
`{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.quDgaCi2rGTRx3qO06CrFvHdeCua_5J7CCVWSaFhkus",
"password": "123456"
}`,
[]string{"token", "password"},
},
// expired token
{
`{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTYwOTQ1NTY2MX0.n1OJXJEACMNPT9aMTO48cVJexIiZEtHsz4UNBIfMcf4",
"password": "123456"
}`,
[]string{"token", "password"},
},
// existing new email
{
`{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.Q_o6zpc2URggTU0mWv2CS0rIPbQhFdmrjZ-ASwHh1Ww",
"password": "1234567890"
}`,
[]string{"token", "password"},
},
// wrong confirmation password
{
`{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
"password": "123456"
}`,
[]string{"password"},
},
// valid data
{
`{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
"password": "1234567890"
}`,
[]string{},
},
}
for i, s := range scenarios {
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
continue
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
record, err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
// parse errors
errs, ok := err.(validation.Errors)
if !ok && err != nil {
t.Errorf("(%d) Failed to parse errors %v", i, err)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
}
}
if len(errs) > 0 {
continue
}
claims, _ := security.ParseUnverifiedJWT(form.Token)
newEmail, _ := claims["newEmail"].(string)
// check whether the user was updated
// ---
if record.Email() != newEmail {
t.Errorf("(%d) Expected record email %q, got %q", i, newEmail, record.Email())
}
if !record.Verified() {
t.Errorf("(%d) Expected record to be verified, got false", i)
}
// shouldn't validate second time due to refreshed record token
if err := form.Validate(); err == nil {
t.Errorf("(%d) Expected error, got nil", i)
}
}
}
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY"
form.Password = "1234567890"
interceptorEmail := authRecord.Email()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorEmail = record.Email()
interceptor2Called = true
return testErr
}
}
_, submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorEmail == authRecord.Email() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+75
View File
@@ -0,0 +1,75 @@
package forms
import (
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/mails"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// RecordEmailChangeRequest is an auth record email change request form.
type RecordEmailChangeRequest struct {
app core.App
dao *daos.Dao
record *models.Record
NewEmail string `form:"newEmail" json:"newEmail"`
}
// NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form
// initialized with from the provided [core.App] and [models.Record] instances.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordEmailChangeRequest(app core.App, record *models.Record) *RecordEmailChangeRequest {
return &RecordEmailChangeRequest{
app: app,
dao: app.Dao(),
record: record,
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordEmailChangeRequest) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordEmailChangeRequest) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.NewEmail,
validation.Required,
validation.Length(1, 255),
is.EmailFormat,
validation.By(form.checkUniqueEmail),
),
)
}
func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
v, _ := value.(string)
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
return validation.NewError("validation_record_email_exists", "User email already exists.")
}
return nil
}
// Submit validates and sends the change email request.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptorsWithRecord(form.record, func(m *models.Record) error {
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
}, interceptors...)
}
+149
View File
@@ -0,0 +1,149 @@
package forms_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
user, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
jsonData string
expectedErrors []string
}{
// empty payload
{"{}", []string{"newEmail"}},
// empty data
{
`{"newEmail": ""}`,
[]string{"newEmail"},
},
// invalid email
{
`{"newEmail": "invalid"}`,
[]string{"newEmail"},
},
// existing email token
{
`{"newEmail": "test2@example.com"}`,
[]string{"newEmail"},
},
// valid new email
{
`{"newEmail": "test_new@example.com"}`,
[]string{},
},
}
for i, s := range scenarios {
testApp.TestMailer.TotalSend = 0 // reset
form := forms.NewRecordEmailChangeRequest(testApp, user)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
continue
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
// parse errors
errs, ok := err.(validation.Errors)
if !ok && err != nil {
t.Errorf("(%d) Failed to parse errors %v", i, err)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
}
}
expectedMails := 1
if len(s.expectedErrors) > 0 {
expectedMails = 0
}
if testApp.TestMailer.TotalSend != expectedMails {
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
}
}
}
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordEmailChangeRequest(testApp, authRecord)
form.NewEmail = "test_new@example.com"
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
}
+234
View File
@@ -0,0 +1,234 @@
package forms
import (
"errors"
"fmt"
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"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// RecordOAuth2Login is an auth record OAuth2 login form.
type RecordOAuth2Login struct {
app core.App
dao *daos.Dao
collection *models.Collection
// Optional auth record that will be used if no external
// auth relation is found (if it is from the same collection)
loggedAuthRecord *models.Record
// The name of the OAuth2 client provider (eg. "google")
Provider string `form:"provider" json:"provider"`
// The authorization code returned from the initial request.
Code string `form:"code" json:"code"`
// The code verifier sent with the initial request as part of the code_challenge.
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
// The redirect url sent with the initial request.
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
// Additional data that will be used for creating a new auth record
// if an existing OAuth2 account doesn't exist.
CreateData map[string]any `form:"createData" json:"createData"`
}
// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
// initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
form := &RecordOAuth2Login{
app: app,
dao: app.Dao(),
collection: collection,
loggedAuthRecord: optAuthRecord,
}
return form
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordOAuth2Login) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required),
validation.Field(&form.CodeVerifier, validation.Required),
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
)
}
func (form *RecordOAuth2Login) checkProviderName(value any) error {
name, _ := value.(string)
config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
if !ok || !config.Enabled {
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
}
return nil
}
// Submit validates and submits the form.
//
// If an auth record doesn't exist, it will make an attempt to create it
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
// You can intercept/modify the create form by setting the optional beforeCreateFuncs argument.
//
// On success returns the authorized record model and the fetched provider's data.
func (form *RecordOAuth2Login) Submit(
beforeCreateFuncs ...func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error,
) (*models.Record, *auth.AuthUser, error) {
if err := form.Validate(); err != nil {
return nil, nil, err
}
if !form.collection.AuthOptions().AllowOAuth2Auth {
return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
}
provider, err := auth.NewProviderByName(form.Provider)
if err != nil {
return nil, nil, err
}
// load provider configuration
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
if err := providerConfig.SetupProvider(provider); err != nil {
return nil, nil, err
}
provider.SetRedirectUrl(form.RedirectUrl)
// fetch token
token, err := provider.FetchToken(
form.Code,
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
)
if err != nil {
return nil, nil, err
}
// fetch external auth user
authUser, err := provider.FetchAuthUser(token)
if err != nil {
return nil, nil, err
}
var authRecord *models.Record
// check for existing relation with the auth record
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
switch {
case rel != nil:
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
if err != nil {
return nil, authUser, err
}
case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
// fallback to the logged auth record (if any)
authRecord = form.loggedAuthRecord
case authUser.Email != "":
// look for an existing auth record by the external auth record's email
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
}
saveErr := form.dao.RunInTransaction(func(txDao *daos.Dao) error {
if authRecord == nil {
authRecord = models.NewRecord(form.collection)
authRecord.RefreshId()
authRecord.MarkAsNew()
createForm := NewRecordUpsert(form.app, authRecord)
createForm.SetFullManageAccess(true)
createForm.SetDao(txDao)
if authUser.Username != "" && usernameRegex.MatchString(authUser.Username) {
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username)
}
// load custom data
createForm.LoadData(form.CreateData)
// load the OAuth2 profile data as fallback
if createForm.Email == "" {
createForm.Email = authUser.Email
}
createForm.Verified = false
if createForm.Email == authUser.Email {
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
createForm.Verified = true
}
if createForm.Password == "" {
createForm.Password = security.RandomString(30)
createForm.PasswordConfirm = createForm.Password
}
for _, f := range beforeCreateFuncs {
if f == nil {
continue
}
if err := f(createForm, authRecord, authUser); err != nil {
return err
}
}
// create the new auth record
if err := createForm.Submit(); err != nil {
return err
}
} else {
// update the existing auth record empty email if the authUser has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
if authRecord.Email() == "" && authUser.Email != "" {
authRecord.SetEmail(authUser.Email)
if err := txDao.SaveRecord(authRecord); err != nil {
return err
}
}
// update the existing auth record verified state
// (only if the auth record doesn't have an email or the auth record email match with the one in authUser)
if !authRecord.Verified() && (authRecord.Email() == "" || authRecord.Email() == authUser.Email) {
authRecord.SetVerified(true)
if err := txDao.SaveRecord(authRecord); err != nil {
return err
}
}
}
// create ExternalAuth relation if missing
if rel == nil {
rel = &models.ExternalAuth{
CollectionId: authRecord.Collection().Id,
RecordId: authRecord.Id,
Provider: form.Provider,
ProviderId: authUser.Id,
}
if err := txDao.SaveExternalAuth(rel); err != nil {
return err
}
}
return nil
})
if saveErr != nil {
return nil, authUser, saveErr
}
return authRecord, authUser, nil
}
@@ -14,40 +14,55 @@ func TestUserOauth2LoginValidate(t *testing.T) {
defer app.Cleanup()
scenarios := []struct {
testName string
collectionName string
jsonData string
expectedErrors []string
}{
// empty payload
{"{}", []string{"provider", "code", "codeVerifier", "redirectUrl"}},
// empty data
{
"empty payload",
"users",
"{}",
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
},
{
"empty data",
"users",
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
},
// missing provider
{
"missing provider",
"users",
`{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{"provider"},
},
// disabled provider
{
"disabled provider",
"users",
`{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{"provider"},
},
// enabled provider
{
"enabled provider",
"users",
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{},
},
}
for i, s := range scenarios {
form := forms.NewUserOauth2Login(app)
for _, s := range scenarios {
authCollection, _ := app.Dao().FindCollectionByNameOrId(s.collectionName)
if authCollection == nil {
t.Errorf("[%s] Failed to fetch auth collection", s.testName)
}
form := forms.NewRecordOAuth2Login(app, authCollection, nil)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
continue
}
@@ -56,17 +71,17 @@ func TestUserOauth2LoginValidate(t *testing.T) {
// parse errors
errs, ok := err.(validation.Errors)
if !ok && err != nil {
t.Errorf("(%d) Failed to parse errors %v", i, err)
t.Errorf("[%s] Failed to parse errors %v", s.testName, err)
continue
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
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("(%d) Missing expected error key %q in %v", i, k, errs)
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package forms
import (
"errors"
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"
)
// RecordPasswordLogin is record username/email + password login form.
type RecordPasswordLogin struct {
app core.App
dao *daos.Dao
collection *models.Collection
Identity string `form:"identity" json:"identity"`
Password string `form:"password" json:"password"`
}
// NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized
// with from the provided [core.App] and [models.Collection] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordPasswordLogin(app core.App, collection *models.Collection) *RecordPasswordLogin {
return &RecordPasswordLogin{
app: app,
dao: app.Dao(),
collection: collection,
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordPasswordLogin) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordPasswordLogin) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
)
}
// Submit validates and submits the form.
// On success returns the authorized record model.
func (form *RecordPasswordLogin) Submit() (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
authOptions := form.collection.AuthOptions()
if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
return nil, errors.New("Password authentication is not allowed for the collection.")
}
var record *models.Record
var fetchErr error
if authOptions.AllowEmailAuth &&
(!authOptions.AllowUsernameAuth || is.EmailFormat.Validate(form.Identity) == nil) {
record, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
} else {
record, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
}
if fetchErr != nil || !record.ValidatePassword(form.Password) {
return nil, errors.New("Invalid login credentials.")
}
return record, nil
}
+130
View File
@@ -0,0 +1,130 @@
package forms_test
import (
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
scenarios := []struct {
testName string
collectionName string
identity string
password string
expectError bool
}{
{
"empty data",
"users",
"",
"",
true,
},
// username
{
"existing username + wrong password",
"users",
"users75657",
"invalid",
true,
},
{
"missing username + valid password",
"users",
"clients57772", // not in the "users" collection
"1234567890",
true,
},
{
"existing username + valid password but in restricted username auth collection",
"clients",
"clients57772",
"1234567890",
true,
},
{
"existing username + valid password but in restricted username and email auth collection",
"nologin",
"test_username",
"1234567890",
true,
},
{
"existing username + valid password",
"users",
"users75657",
"1234567890",
false,
},
// email
{
"existing email + wrong password",
"users",
"test@example.com",
"invalid",
true,
},
{
"missing email + valid password",
"users",
"test_missing@example.com",
"1234567890",
true,
},
{
"existing username + valid password but in restricted username auth collection",
"clients",
"test@example.com",
"1234567890",
false,
},
{
"existing username + valid password but in restricted username and email auth collection",
"nologin",
"test@example.com",
"1234567890",
true,
},
{
"existing email + valid password",
"users",
"test@example.com",
"1234567890",
false,
},
}
for _, s := range scenarios {
authCollection, err := testApp.Dao().FindCollectionByNameOrId(s.collectionName)
if err != nil {
t.Errorf("[%s] Failed to fetch auth collection: %v", s.testName, err)
}
form := forms.NewRecordPasswordLogin(testApp, authCollection)
form.Identity = s.identity
form.Password = s.password
record, err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.testName, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if record.Email() != s.identity && record.Username() != s.identity {
t.Errorf("[%s] Expected record with identity %q, got \n%v", s.testName, s.identity, record)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// RecordPasswordResetConfirm is an auth record password reset confirmation form.
type RecordPasswordResetConfirm struct {
app core.App
collection *models.Collection
dao *daos.Dao
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm]
// form initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordPasswordResetConfirm(app core.App, collection *models.Collection) *RecordPasswordResetConfirm {
return &RecordPasswordResetConfirm{
app: app,
dao: app.Dao(),
collection: collection,
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordPasswordResetConfirm) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordPasswordResetConfirm) Validate() error {
minPasswordLength := form.collection.AuthOptions().MinPasswordLength
return validation.ValidateStruct(form,
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
)
}
func (form *RecordPasswordResetConfirm) checkToken(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
record, err := form.dao.FindAuthRecordByToken(
v,
form.app.Settings().RecordPasswordResetToken.Secret,
)
if err != nil || record == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
}
if record.Collection().Id != form.collection.Id {
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
}
return nil
}
// Submit validates and submits the form.
// On success returns the updated auth record associated to `form.Token`.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
authRecord, err := form.dao.FindAuthRecordByToken(
form.Token,
form.app.Settings().RecordPasswordResetToken.Secret,
)
if err != nil {
return nil, err
}
if err := authRecord.SetPassword(form.Password); err != nil {
return nil, err
}
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
return form.dao.SaveRecord(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return authRecord, nil
}
+192
View File
@@ -0,0 +1,192 @@
package forms_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
jsonData string
expectedErrors []string
}{
// empty data (Validate call check)
{
`{}`,
[]string{"token", "password", "passwordConfirm"},
},
// expired token
{
`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
"password":"12345678",
"passwordConfirm":"12345678"
}`,
[]string{"token"},
},
// valid token but invalid passwords lengths
{
`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"1234567",
"passwordConfirm":"1234567"
}`,
[]string{"password"},
},
// valid token but mismatched passwordConfirm
{
`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345679"
}`,
[]string{"passwordConfirm"},
},
// valid token and password
{
`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345678"
}`,
[]string{},
},
}
for i, s := range scenarios {
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
continue
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
record, submitErr := form.Submit(interceptor)
// parse errors
errs, ok := submitErr.(validation.Errors)
if !ok && submitErr != nil {
t.Errorf("(%d) Failed to parse errors %v", i, submitErr)
continue
}
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
}
}
if len(errs) > 0 || len(s.expectedErrors) > 0 {
continue
}
claims, _ := security.ParseUnverifiedJWT(form.Token)
tokenRecordId := claims["id"]
if record.Id != tokenRecordId {
t.Errorf("(%d) Expected record with id %s, got %v", i, tokenRecordId, record)
}
if !record.LastResetSentAt().IsZero() {
t.Errorf("(%d) Expected record.LastResetSentAt to be empty, got %v", i, record.LastResetSentAt())
}
if !record.ValidatePassword(form.Password) {
t.Errorf("(%d) Expected the record password to have been updated to %q", i, form.Password)
}
}
}
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
form.Password = "1234567890"
form.PasswordConfirm = "1234567890"
interceptorTokenKey := authRecord.TokenKey()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorTokenKey = record.TokenKey()
interceptor2Called = true
return testErr
}
}
_, submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorTokenKey == authRecord.TokenKey() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+91
View File
@@ -0,0 +1,91 @@
package forms
import (
"errors"
"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/mails"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/types"
)
// RecordPasswordResetRequest is an auth record reset password request form.
type RecordPasswordResetRequest struct {
app core.App
dao *daos.Dao
collection *models.Collection
resendThreshold float64 // in seconds
Email string `form:"email" json:"email"`
}
// NewRecordPasswordResetRequest creates a new [RecordPasswordResetRequest]
// form initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordPasswordResetRequest(app core.App, collection *models.Collection) *RecordPasswordResetRequest {
return &RecordPasswordResetRequest{
app: app,
dao: app.Dao(),
collection: collection,
resendThreshold: 120, // 2 min
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordPasswordResetRequest) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
//
// This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit).
func (form *RecordPasswordResetRequest) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Email,
validation.Required,
validation.Length(1, 255),
is.EmailFormat,
),
)
}
// Submit validates and submits the form.
// On success, sends a password reset email to the `form.Email` auth record.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
if err := form.Validate(); err != nil {
return err
}
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
if err != nil {
return err
}
now := time.Now().UTC()
lastResetSentAt := authRecord.LastResetSentAt().Time()
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
return errors.New("You've already requested a password reset.")
}
// update last sent timestamp
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
return err
}
return form.dao.SaveRecord(m)
}, interceptors...)
}
+170
View File
@@ -0,0 +1,170 @@
package forms_test
import (
"encoding/json"
"errors"
"testing"
"time"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestRecordPasswordResetRequestSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
jsonData string
expectError bool
}{
// empty field (Validate call check)
{
`{"email":""}`,
true,
},
// invalid email field (Validate call check)
{
`{"email":"invalid"}`,
true,
},
// nonexisting user
{
`{"email":"missing@example.com"}`,
true,
},
// existing user
{
`{"email":"test@example.com"}`,
false,
},
// existing user - reached send threshod
{
`{"email":"test@example.com"}`,
true,
},
}
now := types.NowDateTime()
time.Sleep(1 * time.Millisecond)
for i, s := range scenarios {
testApp.TestMailer.TotalSend = 0 // reset
form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
continue
}
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if s.expectError {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
expectedMails := 1
if s.expectError {
expectedMails = 0
}
if testApp.TestMailer.TotalSend != expectedMails {
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
}
if s.expectError {
continue
}
// check whether LastResetSentAt was updated
user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
if err != nil {
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
continue
}
if user.LastResetSentAt().Time().Sub(now.Time()) < 0 {
t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt())
}
}
}
func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
form.Email = authRecord.Email()
interceptorLastResetSentAt := authRecord.LastResetSentAt()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorLastResetSentAt = record.LastResetSentAt()
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
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")
}
}
+567 -153
View File
@@ -3,10 +3,15 @@ package forms
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
@@ -15,38 +20,86 @@ import (
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
// RecordUpsert defines a Record upsert form.
type RecordUpsert struct {
app core.App
record *models.Record
// username value regex pattern
var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)
isCreate bool
// RecordUpsert is a [models.Record] upsert (create/update) form.
type RecordUpsert struct {
app core.App
dao *daos.Dao
manageAccess bool
record *models.Record
filesToUpload map[string][]*rest.UploadedFile
filesToDelete []string // names list
filesToUpload []*rest.UploadedFile
// base model fields
Id string `json:"id"`
// auth collection fields
// ---
Username string `json:"username"`
Email string `json:"email"`
EmailVisibility bool `json:"emailVisibility"`
Verified bool `json:"verified"`
Password string `json:"password"`
PasswordConfirm string `json:"passwordConfirm"`
OldPassword string `json:"oldPassword"`
// ---
Data map[string]any `json:"data"`
}
// NewRecordUpsert creates a new Record upsert form.
// (pass a new Record model instance (`models.NewRecord(...)`) for create).
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
// config created from the provided [core.App] and [models.Record] instances
// (for create you could pass a pointer to an empty Record - models.NewRecord(collection)).
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
form := &RecordUpsert{
app: app,
dao: app.Dao(),
record: record,
isCreate: !record.HasId(),
filesToDelete: []string{},
filesToUpload: []*rest.UploadedFile{},
filesToUpload: map[string][]*rest.UploadedFile{},
}
form.loadFormDefaults()
return form
}
// SetFullManageAccess sets the manageAccess bool flag of the current
// form to enable/disable directly changing some system record fields
// (often used with auth collection records).
func (form *RecordUpsert) SetFullManageAccess(fullManageAccess bool) {
form.manageAccess = fullManageAccess
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordUpsert) SetDao(dao *daos.Dao) {
form.dao = dao
}
func (form *RecordUpsert) loadFormDefaults() {
form.Id = form.record.Id
if form.record.Collection().IsAuth() {
form.Username = form.record.Username()
form.Email = form.record.Email()
form.EmailVisibility = form.record.EmailVisibility()
form.Verified = form.record.Verified()
}
form.Data = map[string]any{}
for _, field := range record.Collection().Schema.Fields() {
form.Data[field.Name] = record.GetDataValue(field.Name)
for _, field := range form.record.Collection().Schema.Fields() {
form.Data[field.Name] = form.record.Get(field.Name)
}
return form
}
func (form *RecordUpsert) getContentType(r *http.Request) string {
@@ -59,26 +112,38 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
return t
}
func (form *RecordUpsert) extractRequestData(r *http.Request) (map[string]any, error) {
func (form *RecordUpsert) extractRequestData(r *http.Request, keyPrefix string) (map[string]any, error) {
switch form.getContentType(r) {
case "application/json":
return form.extractJsonData(r)
return form.extractJsonData(r, keyPrefix)
case "multipart/form-data":
return form.extractMultipartFormData(r)
return form.extractMultipartFormData(r, keyPrefix)
default:
return nil, errors.New("Unsupported request Content-Type.")
}
}
func (form *RecordUpsert) extractJsonData(r *http.Request) (map[string]any, error) {
func (form *RecordUpsert) extractJsonData(r *http.Request, keyPrefix string) (map[string]any, error) {
result := map[string]any{}
err := rest.ReadJsonBodyCopy(r, &result)
err := rest.CopyJsonBody(r, &result)
if keyPrefix != "" {
parts := strings.Split(keyPrefix, ".")
for _, part := range parts {
if result[part] == nil {
break
}
if v, ok := result[part].(map[string]any); ok {
result = v
}
}
}
return result, err
}
func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]any, error) {
func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix string) (map[string]any, error) {
result := map[string]any{}
// parse form data (if not already)
@@ -88,7 +153,14 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]
arrayValueSupportTypes := schema.ArraybleFieldTypes()
for key, values := range r.PostForm {
form.filesToUpload = map[string][]*rest.UploadedFile{}
for fullKey, values := range r.PostForm {
key := fullKey
if keyPrefix != "" {
key = strings.TrimPrefix(key, keyPrefix+".")
}
if len(values) == 0 {
result[key] = nil
continue
@@ -102,6 +174,44 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]
}
}
// load uploaded files (if any)
for _, field := range form.record.Collection().Schema.Fields() {
if field.Type != schema.FieldTypeFile {
continue // not a file field
}
key := field.Name
fullKey := key
if keyPrefix != "" {
fullKey = keyPrefix + "." + key
}
files, err := rest.FindUploadedFiles(r, fullKey)
if err != nil || len(files) == 0 {
if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
log.Printf("%q uploaded file error: %v\n", fullKey, err)
}
// skip invalid or missing file(s)
continue
}
options, ok := field.Options.(*schema.FileOptions)
if !ok {
continue
}
if form.filesToUpload[key] == nil {
form.filesToUpload[key] = []*rest.UploadedFile{}
}
if options.MaxSelect == 1 {
form.filesToUpload[key] = append(form.filesToUpload[key], files[0])
} else if options.MaxSelect > 1 {
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
}
}
return result, nil
}
@@ -111,31 +221,66 @@ func (form *RecordUpsert) normalizeData() error {
form.Data[field.Name] = field.PrepareValue(v)
}
}
return nil
}
// LoadData loads and normalizes json OR multipart/form-data request data.
// LoadRequest extracts the json or multipart/form-data request data
// and lods it into the form.
//
// File upload is supported only via multipart/form-data.
//
// To REPLACE previously uploaded file(s) you can suffix the field name
// with the file index (eg. `myfile.0`) and set the new value.
// For single file upload fields, you can skip the index and directly
// assign the file value to the field name (eg. `myfile`).
//
// To DELETE previously uploaded file(s) you can suffix the field name
// with the file index (eg. `myfile.0`) and set it to null or empty string.
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
// reset the field using its field name (eg. `myfile`).
func (form *RecordUpsert) LoadData(r *http.Request) error {
requestData, err := form.extractRequestData(r)
// reset the field using its field name (eg. `myfile = null`).
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
requestData, err := form.extractRequestData(r, keyPrefix)
if err != nil {
return err
}
// extend base data with the extracted one
extendedData := form.record.Data()
return form.LoadData(requestData)
}
// LoadData loads and normalizes the provided data into the form.
//
// To DELETE previously uploaded file(s) you can suffix the field name
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
// reset the field using its field name (eg. `myfile = null`).
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
// load base system fields
if v, ok := requestData["id"]; ok {
form.Id = cast.ToString(v)
}
// load auth system fields
if form.record.Collection().IsAuth() {
if v, ok := requestData["username"]; ok {
form.Username = cast.ToString(v)
}
if v, ok := requestData["email"]; ok {
form.Email = cast.ToString(v)
}
if v, ok := requestData["emailVisibility"]; ok {
form.EmailVisibility = cast.ToBool(v)
}
if v, ok := requestData["verified"]; ok {
form.Verified = cast.ToBool(v)
}
if v, ok := requestData["password"]; ok {
form.Password = cast.ToString(v)
}
if v, ok := requestData["passwordConfirm"]; ok {
form.PasswordConfirm = cast.ToString(v)
}
if v, ok := requestData["oldPassword"]; ok {
form.OldPassword = cast.ToString(v)
}
}
// extend the record schema data with the request data
extendedData := form.record.SchemaData()
rawData, err := json.Marshal(requestData)
if err != nil {
return err
@@ -149,76 +294,82 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
value := extendedData[key]
value = field.PrepareValue(value)
if field.Type == schema.FieldTypeFile {
options, _ := field.Options.(*schema.FileOptions)
oldNames := list.ToUniqueStringSlice(form.Data[key])
if field.Type != schema.FieldTypeFile {
form.Data[key] = value
continue
}
// delete previously uploaded file(s)
if options.MaxSelect == 1 {
// search for unset zero indexed key as a fallback
indexedKeyValue, hasIndexedKey := extendedData[key+".0"]
options, _ := field.Options.(*schema.FileOptions)
oldNames := list.ToUniqueStringSlice(form.Data[key])
if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") {
if len(oldNames) > 0 {
form.filesToDelete = append(form.filesToDelete, oldNames...)
}
form.Data[key] = nil
}
} else if options.MaxSelect > 1 {
// search for individual file index to delete (eg. "file.0")
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
indexesToDelete := []int{}
for indexedKey := range extendedData {
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
if indexErr != nil || index >= len(oldNames) {
continue
}
indexesToDelete = append(indexesToDelete, index)
}
// -----------------------------------------------------------
// Delete previously uploaded file(s)
// -----------------------------------------------------------
// if empty value was set, mark all previously uploaded files for deletion
if len(list.ToUniqueStringSlice(value)) == 0 && len(oldNames) > 0 {
form.filesToDelete = append(form.filesToDelete, oldNames...)
form.Data[key] = []string{}
} else if len(oldNames) > 0 {
indexesToDelete := make([]int, 0, len(extendedData))
// search for individual file name to delete (eg. "file.test.png = null")
for i, name := range oldNames {
if v, ok := extendedData[key+"."+name]; ok && cast.ToString(v) == "" {
indexesToDelete = append(indexesToDelete, i)
}
}
// slice to fill only with the non-deleted indexes
nonDeleted := []string{}
for i, name := range oldNames {
// not marked for deletion
if !list.ExistInSlice(i, indexesToDelete) {
nonDeleted = append(nonDeleted, name)
// search for individual file index to delete (eg. "file.0 = null")
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
for indexedKey := range extendedData {
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
if indexErr != nil || index >= len(oldNames) {
continue
}
// store the id to actually delete the file later
form.filesToDelete = append(form.filesToDelete, name)
indexesToDelete = append(indexesToDelete, index)
}
form.Data[key] = nonDeleted
}
// check if there are any new uploaded form files
files, err := rest.FindUploadedFiles(r, key)
if err != nil {
continue // skip invalid or missing file(s)
}
// refresh oldNames list
oldNames = list.ToUniqueStringSlice(form.Data[key])
if options.MaxSelect == 1 {
// delete previous file(s) before replacing
if len(oldNames) > 0 {
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
// slice to fill only with the non-deleted indexes
nonDeleted := make([]string, 0, len(oldNames))
for i, name := range oldNames {
// not marked for deletion
if !list.ExistInSlice(i, indexesToDelete) {
nonDeleted = append(nonDeleted, name)
continue
}
form.filesToUpload = append(form.filesToUpload, files[0])
form.Data[key] = files[0].Name()
} else if options.MaxSelect > 1 {
// append the id of each uploaded file instance
form.filesToUpload = append(form.filesToUpload, files...)
for _, file := range files {
oldNames = append(oldNames, file.Name())
}
form.Data[key] = oldNames
// store the id to actually delete the file later
form.filesToDelete = append(form.filesToDelete, name)
}
} else {
form.Data[key] = value
form.Data[key] = nonDeleted
}
// -----------------------------------------------------------
// Check for new uploaded file
// -----------------------------------------------------------
if len(form.filesToUpload[key]) == 0 {
continue
}
// refresh oldNames list
oldNames = list.ToUniqueStringSlice(form.Data[key])
if options.MaxSelect == 1 {
// delete previous file(s) before replacing
if len(oldNames) > 0 {
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
}
form.Data[key] = form.filesToUpload[key][0].Name()
} else if options.MaxSelect > 1 {
// append the id of each uploaded file instance
for _, file := range form.filesToUpload[key] {
oldNames = append(oldNames, file.Name())
}
form.Data[key] = oldNames
}
}
@@ -227,13 +378,222 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordUpsert) Validate() error {
dataValidator := validators.NewRecordDataValidator(
form.app.Dao(),
// base form fields validator
baseFieldsRules := []*validation.FieldRules{
validation.Field(
&form.Id,
validation.When(
form.record.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.record.Id)),
),
}
// auth fields validators
if form.record.Collection().IsAuth() {
baseFieldsRules = append(baseFieldsRules,
validation.Field(
&form.Username,
// require only on update, because on create we fallback to auto generated username
validation.When(!form.record.IsNew(), validation.Required),
validation.Length(3, 100),
validation.Match(usernameRegex),
validation.By(form.checkUniqueUsername),
),
validation.Field(
&form.Email,
validation.When(
form.record.Collection().AuthOptions().RequireEmail,
validation.Required,
),
// don't allow direct email change (or unset) if the form doesn't have manage access permissions
// (aka. allow only admin or authorized auth models to directly update the field)
validation.When(
!form.record.IsNew() && !form.manageAccess,
validation.In(form.record.Email()),
),
validation.Length(1, 255),
is.EmailFormat,
validation.By(form.checkEmailDomain),
validation.By(form.checkUniqueEmail),
),
validation.Field(
&form.Verified,
// don't allow changing verified if the form doesn't have manage access permissions
// (aka. allow only admin or authorized auth models to directly change the field)
validation.When(
!form.manageAccess,
validation.In(form.record.Verified()),
),
),
validation.Field(
&form.Password,
validation.When(form.record.IsNew(), validation.Required),
validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
),
validation.Field(
&form.PasswordConfirm,
validation.When(
(form.record.IsNew() || form.Password != ""),
validation.Required,
),
validation.By(validators.Compare(form.Password)),
),
validation.Field(
&form.OldPassword,
// require old password only on update when:
// - form.manageAccess is not set
// - changing the existing password
validation.When(
!form.record.IsNew() && !form.manageAccess && form.Password != "",
validation.Required,
validation.By(form.checkOldPassword),
),
),
)
}
if err := validation.ValidateStruct(form, baseFieldsRules...); err != nil {
return err
}
// record data validator
return validators.NewRecordDataValidator(
form.dao,
form.record,
form.filesToUpload,
)
).Validate(form.Data)
}
return dataValidator.Validate(form.Data)
func (form *RecordUpsert) checkUniqueUsername(value any) error {
v, _ := value.(string)
if v == "" {
return nil
}
isUnique := form.dao.IsRecordValueUnique(
form.record.Collection().Id,
schema.FieldNameUsername,
v,
form.record.Id,
)
if !isUnique {
return validation.NewError("validation_invalid_username", "The username is invalid or already in use.")
}
return nil
}
func (form *RecordUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string)
if v == "" {
return nil
}
isUnique := form.dao.IsRecordValueUnique(
form.record.Collection().Id,
schema.FieldNameEmail,
v,
form.record.Id,
)
if !isUnique {
return validation.NewError("validation_invalid_email", "The email is invalid or already in use.")
}
return nil
}
func (form *RecordUpsert) checkEmailDomain(value any) error {
val, _ := value.(string)
if val == "" {
return nil // nothing to check
}
domain := val[strings.LastIndex(val, "@")+1:]
only := form.record.Collection().AuthOptions().OnlyEmailDomains
except := form.record.Collection().AuthOptions().ExceptEmailDomains
// only domains check
if len(only) > 0 && !list.ExistInSlice(domain, only) {
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
}
// except domains check
if len(except) > 0 && list.ExistInSlice(domain, except) {
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
}
return nil
}
func (form *RecordUpsert) checkOldPassword(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
if !form.record.ValidatePassword(v) {
return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.")
}
return nil
}
func (form *RecordUpsert) ValidateAndFill() error {
if err := form.Validate(); err != nil {
return err
}
isNew := form.record.IsNew()
// custom insertion id can be set only on create
if isNew && form.Id != "" {
form.record.SetId(form.Id)
form.record.MarkAsNew()
}
// set auth fields
if form.record.Collection().IsAuth() {
// generate a default username during create (if missing)
if form.record.IsNew() && form.Username == "" {
baseUsername := form.record.Collection().Name + security.RandomStringWithAlphabet(5, "123456789")
form.Username = form.dao.SuggestUniqueAuthRecordUsername(form.record.Collection().Id, baseUsername)
}
if form.Username != "" {
if err := form.record.SetUsername(form.Username); err != nil {
return err
}
}
if isNew || form.manageAccess {
if err := form.record.SetEmail(form.Email); err != nil {
return err
}
}
if err := form.record.SetEmailVisibility(form.EmailVisibility); err != nil {
return err
}
if form.manageAccess {
if err := form.record.SetVerified(form.Verified); err != nil {
return err
}
}
if form.Password != "" {
if err := form.record.SetPassword(form.Password); err != nil {
return err
}
}
}
// bulk load the remaining form data
form.record.Load(form.Data)
return nil
}
// DrySubmit performs a form submit within a transaction and reverts it.
@@ -241,21 +601,21 @@ func (form *RecordUpsert) Validate() error {
//
// This method doesn't handle file uploads/deletes or trigger any app events!
func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
if err := form.Validate(); err != nil {
return err
}
isNew := form.record.IsNew()
// bulk load form data
if err := form.record.Load(form.Data); err != nil {
if err := form.ValidateAndFill(); err != nil {
return err
}
// use the default app.Dao to prevent changing the transaction form.Dao
// and causing "transaction has already been committed or rolled back" error
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
tx, ok := txDao.DB().(*dbx.Tx)
if !ok {
return errors.New("failed to get transaction db")
}
defer tx.Rollback()
txDao.BeforeCreateFunc = nil
txDao.AfterCreateFunc = nil
txDao.BeforeUpdateFunc = nil
@@ -265,49 +625,80 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
return err
}
return callback(txDao)
})
}
// Submit validates the form and upserts the form Record model.
func (form *RecordUpsert) Submit() error {
if err := form.Validate(); err != nil {
return err
}
// bulk load form data
if err := form.record.Load(form.Data); err != nil {
return err
}
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// persist record model
if err := txDao.SaveRecord(form.record); err != nil {
return err
// restore record isNew state
if isNew {
form.record.MarkAsNew()
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return err
}
// delete old files (if any)
if err := form.processFilesToDelete(); err != nil {
// for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)...
if callback != nil {
return callback(txDao)
}
return nil
})
}
// Submit validates the form and upserts the form Record model.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
if err := form.ValidateAndFill(); err != nil {
return err
}
return runInterceptors(func() error {
if !form.record.HasId() {
form.record.RefreshId()
form.record.MarkAsNew()
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return fmt.Errorf("failed to process the uploaded files: %w", err)
}
// persist the record model
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
// try to cleanup the successfully uploaded files
if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() {
log.Println(err)
}
return fmt.Errorf("failed to save the record: %w", saveErr)
}
// delete old files (if any)
//
// for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)...
if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
log.Println(err)
}
return nil
}, interceptors...)
}
func (form *RecordUpsert) getFilesToUploadNames() []string {
names := []string{}
for fieldKey := range form.filesToUpload {
for _, file := range form.filesToUpload[fieldKey] {
names = append(names, file.Name())
}
}
return names
}
func (form *RecordUpsert) processFilesToUpload() error {
if len(form.filesToUpload) == 0 {
return nil // nothing to upload
return nil // no parsed file fields
}
if !form.record.HasId() {
return errors.New("The record is not persisted yet.")
return errors.New("the record is not persisted yet")
}
fs, err := form.app.NewFilesystem()
@@ -316,52 +707,75 @@ func (form *RecordUpsert) processFilesToUpload() error {
}
defer fs.Close()
for i := len(form.filesToUpload) - 1; i >= 0; i-- {
file := form.filesToUpload[i]
path := form.record.BaseFilesPath() + "/" + file.Name()
var uploadErrors []error // list of upload errors
var uploaded []string // list of uploaded file paths
if err := fs.Upload(file.Bytes(), path); err == nil {
form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...)
for fieldKey := range form.filesToUpload {
for i, file := range form.filesToUpload[fieldKey] {
path := form.record.BaseFilesPath() + "/" + file.Name()
if err := fs.UploadMultipart(file.Header(), path); err == nil {
// keep track of the already uploaded file
uploaded = append(uploaded, path)
} else {
// store the upload error
uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err))
}
}
}
if len(form.filesToUpload) > 0 {
return errors.New("Failed to upload all files.")
if len(uploadErrors) > 0 {
// cleanup - try to delete the successfully uploaded files (if any)
form.deleteFilesByNamesList(uploaded)
return fmt.Errorf("failed to upload all files: %v", uploadErrors)
}
return nil
}
func (form *RecordUpsert) processFilesToDelete() error {
if len(form.filesToDelete) == 0 {
return nil // nothing to delete
func (form *RecordUpsert) processFilesToDelete() (err error) {
form.filesToDelete, err = form.deleteFilesByNamesList(form.filesToDelete)
return
}
// deleteFiles deletes a list of record files by their names.
// Returns the failed/remaining files.
func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string, error) {
if len(filenames) == 0 {
return filenames, nil // nothing to delete
}
if !form.record.HasId() {
return errors.New("The record is not persisted yet.")
return filenames, errors.New("the record doesn't have a unique ID")
}
fs, err := form.app.NewFilesystem()
if err != nil {
return err
return filenames, err
}
defer fs.Close()
for i := len(form.filesToDelete) - 1; i >= 0; i-- {
filename := form.filesToDelete[i]
var deleteErrors []error
for i := len(filenames) - 1; i >= 0; i-- {
filename := filenames[i]
path := form.record.BaseFilesPath() + "/" + filename
if err := fs.Delete(path); err == nil {
form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...)
// remove the deleted file from the list
filenames = append(filenames[:i], filenames[i+1:]...)
// try to delete the related file thumbs (if any)
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
} else {
// store the delete error
deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err))
}
// try to delete the related file thumbs (if any)
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
}
if len(form.filesToDelete) > 0 {
return errors.New("Failed to delete all files.")
if len(deleteErrors) > 0 {
return filenames, fmt.Errorf("failed to delete all files: %v", deleteErrors)
}
return nil
return filenames, nil
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
// RecordVerificationConfirm is an auth record email verification confirmation form.
type RecordVerificationConfirm struct {
app core.App
collection *models.Collection
dao *daos.Dao
Token string `form:"token" json:"token"`
}
// NewRecordVerificationConfirm creates a new [RecordVerificationConfirm]
// form initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordVerificationConfirm(app core.App, collection *models.Collection) *RecordVerificationConfirm {
return &RecordVerificationConfirm{
app: app,
dao: app.Dao(),
collection: collection,
}
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordVerificationConfirm) SetDao(dao *daos.Dao) {
form.dao = dao
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordVerificationConfirm) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
)
}
func (form *RecordVerificationConfirm) checkToken(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
claims, _ := security.ParseUnverifiedJWT(v)
email := cast.ToString(claims["email"])
if email == "" {
return validation.NewError("validation_invalid_token_claims", "Missing email token claim.")
}
record, err := form.dao.FindAuthRecordByToken(
v,
form.app.Settings().RecordVerificationToken.Secret,
)
if err != nil || record == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
}
if record.Collection().Id != form.collection.Id {
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
}
if record.Email() != email {
return validation.NewError("validation_token_email_mismatch", "The record email doesn't match with the requested token claims.")
}
return nil
}
// Submit validates and submits the form.
// On success returns the verified auth record associated to `form.Token`.
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
record, err := form.dao.FindAuthRecordByToken(
form.Token,
form.app.Settings().RecordVerificationToken.Secret,
)
if err != nil {
return nil, err
}
wasVerified := record.Verified()
if !wasVerified {
record.SetVerified(true)
}
interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error {
if wasVerified {
return nil // already verified
}
return form.dao.SaveRecord(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return record, nil
}

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