Compare commits

..
329 Commits
Author SHA1 Message Date
Gani Georgiev af34eb544d [#1989] fixed RecordInfo multiple files preview 2023-03-06 13:33:15 +02:00
Gani Georgiev 42e288c71a [#1976] added HEAD requests support for the file download action 2023-03-05 15:39:18 +02:00
Gani Georgiev dc96a12dc0 updated ui/dist 2023-02-26 11:00:52 +02:00
Gani Georgiev 0bb58eb52c minor UI fixes 2023-02-26 10:47:00 +02:00
Gani Georgiev cf6d325add added model query autocancellation test 2023-02-24 22:07:25 +02:00
Gani Georgiev 578e1c9bc1 [#223] updated the internal redirects to allow easier subpath deployment when behind a reverse proxy 2023-02-24 18:49:46 +02:00
Gani Georgiev 4778fc7a46 added min select relation field option 2023-02-24 16:34:02 +02:00
Gani Georgiev f1a6a82bd3 prevent collectionId relation field change 2023-02-24 14:12:27 +02:00
Gani Georgiev 21b152b58c fixed formatting and typos 2023-02-23 21:51:42 +02:00
Gani Georgiev aa4e405f92 replaced authentik with generic oidc provider 2023-02-23 21:07:00 +02:00
Gani Georgiev e529fe7e2a added --queryTimeout flag 2023-02-23 18:59:23 +02:00
Gani Georgiev 6ab2fa9489 updated default query timeout to 1m 2023-02-23 12:04:16 +02:00
Gani Georgiev 010a396b0e updated dao fail/retry handling 2023-02-22 22:20:19 +02:00
Gani Georgiev 65a148b741 added UploadedFiles to the record create/update events 2023-02-22 22:09:13 +02:00
Gani Georgiev 503c65a767 [#1896] run files upload after record save 2023-02-22 22:06:59 +02:00
Gani Georgiev eec3261d67 added cancelKey to the RecordsList component 2023-02-22 18:02:08 +02:00
Gani Georgiev 0db6c783cd minor ui improvements 2023-02-21 22:24:49 +02:00
Gani Georgiev 4fdc8feafc updated daos.HasTable to check also for views 2023-02-21 20:03:27 +02:00
Gani Georgiev 41c3cc8a90 added select auto fail/retry 2023-02-21 16:54:08 +02:00
Gani Georgiev 0afb09b3bd updated view ui 2023-02-21 16:32:58 +02:00
Gani Georgiev 1075292321 fixed godoc formatting 2023-02-20 13:31:03 +02:00
Gani Georgiev b184ef6c3a fixed daos.SaveView test 2023-02-18 20:31:41 +02:00
Gani Georgiev 5fd7f61656 temporary change the github action's go version to 1.19 2023-02-18 20:06:22 +02:00
Gani Georgiev 851711e231 updated go deps 2023-02-18 20:02:47 +02:00
Gani Georgiev a07f67002f added view collection type 2023-02-18 19:33:42 +02:00
Gani Georgiev 0052e2ab2a synced with master 2023-02-12 14:11:12 +02:00
Gani Georgiev 0948bf416d [#1822] logged the current datetime on server start 2023-02-12 12:41:48 +02:00
Gani Georgiev 9fd6e7fca1 [#1836] fixed toggle column reactivity on collection change 2023-02-12 12:35:49 +02:00
Gani Georgiev fa65a20202 updated changelog 2023-02-06 19:57:43 +02:00
Gani Georgiev 668698feb2 [#1797] enabled goja/process module for accessing the os.Environ 2023-02-06 19:53:21 +02:00
Gani Georgiev f475967a4a updated default tokenizer separators 2023-02-06 16:30:47 +02:00
Gani Georgiev 23dfa9c634 added generic tokenizer helper 2023-02-05 20:59:17 +02:00
Gani Georgiev 1b21e86be6 synced with master 2023-02-02 15:35:03 +02:00
Gani Georgiev dcaacd0d28 updated ui/dist and slighly increased the colors contrast 2023-02-02 14:09:25 +02:00
Gani Georgiev 21d7cff43d updated changelog 2023-02-02 13:33:05 +02:00
Gani Georgiev 2aec49a1f1 autoclose multi-select dropdown if max select is reached 2023-02-02 13:31:17 +02:00
Gani Georgiev 17b7ee5200 updated the github action's min go version to 1.20.0 2023-02-02 00:21:24 +02:00
Gani Georgiev 2b5f00b62e fix[#1730, #1742] fixed datepicker clear btn and increased theslightly the fields contrast 2023-02-02 00:19:12 +02:00
Gani Georgiev 7d38971850 updated changelog example 2023-02-01 22:19:36 +02:00
Gani Georgiev 15102138c3 updated changelog 2023-02-01 22:10:00 +02:00
Gani Georgiev 2378bc72c5 [#1728] normalized mailer.Message recipient fields 2023-02-01 22:07:46 +02:00
Gani Georgiev 69b80123de Merge branch 'master' into develop 2023-01-30 17:26:40 +02:00
Gani Georgiev 8b93aac8e3 unregister closed/destroyed overlay panels 2023-01-30 16:31:19 +02:00
Gani Georgiev 250642a8f9 allowed overwriting the default file serve headers if an explicit response header is set 2023-01-30 12:54:51 +02:00
Gani Georgiev eb51cdf1aa use the select/deselect helpers on inline RecordUpsert save and delete 2023-01-30 12:10:57 +02:00
Gani Georgiev 647997517f updated changelog 2023-01-30 11:59:14 +02:00
Gani Georgiev 0e1f6b69d0 [#1718] fixed helper overlay-active class not being toggled correctly 2023-01-30 11:56:05 +02:00
Gani Georgiev 6718f4469b updated changelog 2023-01-30 10:18:30 +02:00
Gani Georgiev c27349905c updated js deps 2023-01-30 10:15:02 +02:00
Gani Georgiev 1b48bdb81a fixed changelog typos 2023-01-29 23:23:45 +02:00
Gani Georgiev bb801e7de0 [#1711] added the collection name in the page title 2023-01-29 22:59:11 +02:00
Gani Georgiev adf902cae7 removed unused plugin and legacy license comments from the generated editor theme 2023-01-29 22:29:09 +02:00
Gani Georgiev d2a617848d fixed empty relation(s) save js error 2023-01-29 22:10:14 +02:00
Gani Georgiev 7ae2a7e846 added missing required class to the authentik User API URL 2023-01-29 21:20:41 +02:00
Gani Georgiev 448120bf18 updated ui/dist 2023-01-29 20:13:24 +02:00
Gani Georgiev 667bcac680 updated initial json field reactivity 2023-01-29 20:05:11 +02:00
Gani Georgiev 64fd347628 updated changelog 2023-01-29 18:55:33 +02:00
Gani Georgiev 0340d8add8 updated ui/dist 2023-01-29 18:53:39 +02:00
Hung TranandHarryTr 2c9e8995f9 [#1707] case-insensitive filename extension check
Co-authored-by: HarryTr <hung.tv@hanbiro.com>
2023-01-29 18:52:14 +02:00
Gani Georgiev f01f1df07a updated changelog and json string normalization note 2023-01-29 16:55:46 +02:00
Gani Georgiev f4533f3d26 updated comments and added json string value normalizations info panel 2023-01-29 16:00:03 +02:00
Gani Georgiev 7a47a8a979 updated changelog 2023-01-29 14:16:33 +02:00
Gani Georgiev deccb3dbdb [#1703] updated json field string data normalizations and fixed the field vizualization in the Admin UI 2023-01-29 12:37:10 +02:00
Gani GeorgievandNolan Darilek c51148e4d7 [#1702] added aria-label to some buttons for accessibility
Co-authored-by: Nolan Darilek <nolan@thewordnerd.info>
2023-01-28 20:14:51 +02:00
Gani Georgiev 51ee1b5367 updated tagged hook methods to use h as short var 2023-01-28 20:10:02 +02:00
Gani Georgiev b8d7609e9e added support for optional Model and Record event hook tags 2023-01-27 22:19:08 +02:00
Gani Georgiev 32af49dbec fixed wrong < and ?< operators description 2023-01-27 00:07:37 +02:00
Gani Georgiev cf9e2a33bb updated go deps and added Enclose for grouped conditions for normalization 2023-01-26 22:24:39 +02:00
Gani Georgiev a27298d1ef reset fields id on collection duplicate 2023-01-26 12:12:14 +02:00
Gani Georgiev 536707bfe7 sync with master 2023-01-26 09:23:59 +02:00
Gani Georgiev 2128b15541 updated changelog and ui/dist 2023-01-26 00:11:28 +02:00
Gani Georgiev eb1246fc41 [#1689] fixed cascade delete condition on rel records with the same id as the main record 2023-01-26 00:05:20 +02:00
Gani Georgiev ae371e8481 refactored Record.data and Record.expand to be concurrent safe 2023-01-25 22:39:42 +02:00
Gani Georgiev 39df263a03 [#1656] added duplicate collection and record dropdown option 2023-01-24 22:30:42 +02:00
Gani Georgiev b3fa1f0fea removed n/a values from the mime types list 2023-01-24 22:25:38 +02:00
Gani Georgiev ecfae2e5c9 added predefined mime types list and other minor ui improvements 2023-01-24 20:58:24 +02:00
Gani Georgiev e5477961ad removed unused IdLabel component 2023-01-24 12:45:05 +02:00
Gani Georgiev 2d40487b21 [#1651] added more detailed file upload errors 2023-01-24 12:40:49 +02:00
Gani Georgiev 8564a69a94 updated the default editor toolbar 2023-01-23 22:17:50 +02:00
Gani Georgiev 3f58908734 make the missing displayValue configurable 2023-01-23 22:09:28 +02:00
Gani Georgiev 4c010847e3 [#976] added optional RelationOptions.DisplayFields and refactored the relation picker UI 2023-01-23 21:57:35 +02:00
Gani GeorgievandSteve MacLeod 4c73e16f54 [#1643] added Gitea OAuth2 provider
Co-authored-by: Steve MacLeod <sjmacleoddev@gmail.com>
2023-01-20 10:17:57 +02:00
Gani Georgiev 2a34eca07a synced with master 2023-01-18 17:17:01 +02:00
Gani Georgiev a74d227418 updated ui/dist 2023-01-18 15:49:19 +02:00
Gani Georgiev 7001a22d92 [#1628] fixed realtime panic on concurrent clients iteration 2023-01-18 15:42:04 +02:00
Gani Georgiev a7e3f08df0 fixed typo 2023-01-18 10:05:45 +02:00
Gani Georgiev 15583ba718 updated ui/dist 2023-01-17 23:05:10 +02:00
Gani Georgiev e25c252fc2 [#1623] added apis.RecordAuthResponse helper 2023-01-17 23:04:13 +02:00
Gani Georgiev a15b192a42 added link to the mimetypes supported list 2023-01-17 15:32:16 +02:00
Gani Georgiev 2a4b3315c6 [#370] added rich text editor field 2023-01-17 13:31:48 +02:00
Gani GeorgievandMarc Singer 6d08a5f36f [#1377] added Authentik OAuth2 provider
Co-authored-by: Marc Singer <ms@pr0.tech>
2023-01-16 11:50:45 +02:00
Gani Georgiev fd97732d4d reasign the OAuth2 event fields to make sure that the event always have the latest OAuth2 state 2023-01-15 17:14:52 +02:00
Gani Georgiev 36ab3fd162 [#1240] added dedicated before/after auth hooks and refactored the submit interceptors 2023-01-15 17:00:28 +02:00
Gani Georgiev 8f6f87902a updated README with the supported noncgo platforms 2023-01-14 13:50:19 +02:00
Gani GeorgievandMarios Antonoudiou 55c6bed57f [#1573] added LiveChat OAuth2 provider
Co-authored-by: Marios Antonoudiou <m.antonoudiou@celonis.com>
2023-01-12 22:17:20 +02:00
Gani Georgiev ba7c8e2108 synced with master 2023-01-12 21:18:43 +02:00
Gani Georgiev c1921aeef8 updated changelog 2023-01-12 15:42:46 +02:00
Gani Georgiev 012546e838 removed delete worker pool since it is no longer needed and changed the files delete operation to run in the background (will be replaced with job queue) 2023-01-12 15:34:56 +02:00
Gani Georgiev f792a9e08d fixed ListBucket iterator to always break on seek/forward error 2023-01-12 15:19:27 +02:00
Gani Georgiev 5fb1e85372 fixed formatting 2023-01-12 13:44:37 +02:00
Andrei Varabyeu a5ceee33df Allows files to be read through FileSystem interface.
The functionality is needed while Pocketbase is used in embedded mode
2023-01-12 13:40:40 +02:00
Gani Georgiev 59e4939e1d added unique id validator error 2023-01-11 22:29:48 +02:00
Gani Georgiev 1f46b30895 Merge branch 'master' into develop 2023-01-11 15:56:21 +02:00
Gani Georgiev a8b2f0f6f1 updated min github action go version to 1.19.5 2023-01-11 14:53:24 +02:00
Gani Georgiev d37bf6452c updated files preview ui 2023-01-10 22:20:52 +02:00
Gani Georgiev c26ac2d53f upgraded vite 2023-01-10 15:26:36 +02:00
Gani Georgiev e1c751a7e7 synced with master 2023-01-10 15:14:33 +02:00
Gani Georgiev f7d4722052 [#1552] unescaped path parameter values 2023-01-09 22:36:28 +02:00
mjadobson 7459c9208f [#1548] added PDF preview 2023-01-09 21:41:27 +02:00
Gani Georgiev c1ff1c6155 updated go deps 2023-01-09 21:29:20 +02:00
Gani Georgiev 3dc1bf6fa7 updated go deps 2023-01-08 11:32:19 +02:00
Gani Georgiev d3cc87abee updated rule field styles 2023-01-08 11:11:02 +02:00
Gani Georgiev 19ad827302 updated migration timestamp 2023-01-08 11:05:44 +02:00
Gani Georgiev 40830b6c43 updated API preview docs 2023-01-08 10:31:21 +02:00
Gani Georgiev f234132629 [#1523] added Docs link in the footer 2023-01-08 00:25:09 +02:00
Gani Georgiev 9b880f5ab4 filter enhancements 2023-01-07 22:27:11 +02:00
Gani Georgiev d5775ff657 updated ui/dist and changelog 2023-01-02 23:06:19 +02:00
Khairul Haaziq 41ba7e12e1 [#1469] added webp to the image mime types presets 2023-01-02 23:02:14 +02:00
Gani Georgiev 5ef6b3a8be updated changelog 2022-12-31 16:53:45 +02:00
Gani Georgiev c673d9d314 updated github and gitee optional email fetch handling 2022-12-31 16:45:42 +02:00
Gani Georgiev a7aa3da67e Merge branch 'master' into yuxiang-gao-gitee-oauth 2022-12-31 15:00:19 +02:00
Gani Georgiev 0439af458f updated the GitHub provider to ignore extra emails request errors in case of unsufficient custom scopes 2022-12-31 14:58:30 +02:00
Yuxiang Gao 6f3241399c Update UI
Signed-off-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2022-12-31 18:05:49 +08:00
Yuxiang Gao 19ccc70fe5 Added gitee OAuth
Signed-off-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2022-12-31 17:57:40 +08:00
Gani Georgiev 9033cd109e updated changelog 2022-12-31 11:01:58 +02:00
Gani Georgiev e1773eead0 synced with master 2022-12-31 10:58:25 +02:00
Gani Georgiev f6fff85d07 updated changelog 2022-12-31 10:11:24 +02:00
Gani Georgiev 166d2eafc7 [#1447] fixed records listing number field value output 2022-12-31 10:08:04 +02:00
szsascha d710446c71 Implement strava oauth2 as new auth provider 2022-12-31 01:21:41 +01:00
Gani Georgiev 8f6cb5ad2d updated changelog and godoc 2022-12-29 18:58:07 +02:00
David Schissler 775417ac2b [#1420] added filesystem.NewFileFromBytes 2022-12-29 18:51:27 +02:00
Gani Georgiev 079616ee8e [#1416] fixed listing searchbar text wrapping 2022-12-28 22:27:42 +02:00
Gani Georgiev 26b794aa08 updated changelog 2022-12-24 15:54:14 +02:00
tenthree d55610511d [#1370] added IME status checking to the textarea keydown handler 2022-12-24 15:50:51 +02:00
Gani Georgiev a00606d969 updated changelog and ui/dist 2022-12-24 08:40:08 +02:00
Gani Georgiev 4abc8ae021 [#1365] fixed Record.MergeExpand panic 2022-12-24 08:34:42 +02:00
Gani Georgiev f91f009fce updated go deps 2022-12-22 16:16:21 +02:00
Gani Georgiev 7fc1d979dd added fs.UploadFile unit test and updated changelog 2022-12-22 16:06:44 +02:00
Yuxiang Gao ede7804a80 [#1343] fixed s3 file upload error caused by underscore in metadata 2022-12-22 15:39:45 +02:00
Gani Georgiev cea287a2c1 updated ui/dist 2022-12-21 19:18:57 +02:00
Joysankar Majumdar 9e03ac4dc4 [#1332] fixed request verification docs api url 2022-12-21 19:17:05 +02:00
Gani Georgiev e713de1e44 updated excluded meta props in the relation select option 2022-12-21 09:45:14 +02:00
Gani Georgiev 233ab62f8e updated js deps 2022-12-20 20:27:13 +02:00
Gani Georgiev 3449084e54 hinted indexed map size 2022-12-20 20:25:38 +02:00
Gani Georgiev 15c05b9679 fixed changelog typo 2022-12-20 11:09:21 +02:00
Gani Georgiev fb57c8091d [#586] fixed nested multiple expands with shared path 2022-12-20 11:07:16 +02:00
Gani Georgiev ca528cef03 improved auth record errors reporting and updated nested tx test 2022-12-18 14:06:48 +02:00
Gani Georgiev 4ceab4e7ed updated nested tx test 2022-12-18 13:49:31 +02:00
Gani Georgiev e8fa51526a updated ui/dist 2022-12-18 11:40:49 +02:00
Gani Georgiev a43713ce14 [#1291] added condition to switch between the db pools in case of dry submit 2022-12-18 11:32:15 +02:00
Gani Georgiev 7d7d20744e fixed test error message 2022-12-18 11:13:09 +02:00
Gani Georgiev bd16680548 [#1291] fixed nested tx deadlock when creating new user with OAuth2 2022-12-18 11:11:34 +02:00
Gani Georgiev 84ba89d5af fixed changelog typos 2022-12-16 17:24:28 +02:00
Gani Georgiev bf5b1db672 added missed IsDebug check 2022-12-16 17:15:04 +02:00
Gani Georgiev 71d3f8f4c7 log OnMailAfter* hook errors in debug mode 2022-12-16 17:07:42 +02:00
Gani Georgiev 687a79b450 updated linter 2022-12-16 17:06:03 +02:00
Gani Georgiev 738f71f244 updated changelog 2022-12-16 16:32:32 +02:00
Gani Georgiev 64f3c5a604 go mod tidy 2022-12-16 16:30:01 +02:00
Gani Georgiev a8c996c93d updated go deps 2022-12-16 16:28:02 +02:00
Gani Georgiev 9ae8536515 updated base_test error messages 2022-12-16 13:37:28 +02:00
Gani Georgiev 5f6b7f6cc0 updated ui/dist 2022-12-16 13:09:03 +02:00
Gani Georgiev 89de29fc84 updated code comments and renamed async/sync db to concurrent/nonconcurrent db 2022-12-16 13:07:58 +02:00
Gani Georgiev c25e67e13d [#1267] call app.Bootstrap() before cobra commands execution 2022-12-15 23:20:23 +02:00
Gani Georgiev 8e582acbee defined Default* constants for the pool limits 2022-12-15 18:10:31 +02:00
Gani Georgiev b9e257d2b1 added split (sync and async) db connections pool 2022-12-15 16:42:35 +02:00
Gani Georgiev e964b019c2 fixed changelog typo 2022-12-14 12:38:54 +02:00
Gani Georgiev 4cbb7f58cd updated changelog 2022-12-14 12:32:11 +02:00
Gani Georgiev 8815f6060c reduced the parenthesis in the generated filter sql query 2022-12-14 12:29:43 +02:00
Gani Georgiev 5183280c39 updated changelog 2022-12-13 12:33:12 +02:00
Gani Georgiev 1f45b858a6 [#1217] add support for smtp LOGIN auth 2022-12-13 11:45:59 +02:00
Gani Georgiev 6d46cefd1f updated delete comment 2022-12-13 09:08:54 +02:00
Gani Georgiev b1a63bb98d minor code optimizations 2022-12-13 09:07:50 +02:00
Gani Georgiev 1248421703 updated ui deps 2022-12-12 19:35:49 +02:00
Gani Georgiev be3dd42eac batched rel references and added test for the batch delete processing 2022-12-12 19:21:54 +02:00
Gani Georgiev 0eeae9de80 updated random_test 2022-12-12 19:19:31 +02:00
Gani Georgiev 55b439cb1c updated changelog 2022-12-12 17:23:00 +02:00
Gani Georgiev 0696a252cc fixed comment formatting 2022-12-12 17:22:43 +02:00
Gani Georgiev 54c52f696c set map size to the shallowCopy 2022-12-12 15:58:56 +02:00
Gani Georgiev 21f442293f [#356] trigger password validators when any of the password related fields is set 2022-12-12 12:42:15 +02:00
Gani Georgiev 18d6a1c529 optimized record references deletion 2022-12-11 22:23:02 +02:00
Marvin Wendt ad321c01e0 [#1237] added security policy 2022-12-11 18:55:55 +02:00
Gani Georgiev 29c99319dc updated changelog 2022-12-11 17:36:15 +02:00
Gani Georgiev 18285e7505 updated ui/dist 2022-12-11 17:34:35 +02:00
Gani Georgiev 846b56d393 updated connection pool limits 2022-12-11 17:32:56 +02:00
Gani Georgiev f30c9f263f removed comment and applied linter 2022-12-11 17:32:43 +02:00
Gani Georgiev b63268559f [#1231] fixed like escape expr 2022-12-11 17:30:25 +02:00
Marvin Wendt 5c899a4cf0 [#1233] added health API endpoint 2022-12-11 17:27:46 +02:00
Gani Georgiev 506bfca8b2 removed logs fails/retry since it is now handled by default in daos.Dao 2022-12-11 01:39:13 +02:00
Gani Georgiev 007fcd36b8 updated changelog 2022-12-11 01:10:22 +02:00
Gani Georgiev 707f35f461 [#1194] refactored forms.RecordUpsert to allow easier file upload 2022-12-11 01:01:15 +02:00
Gani Georgiev 972b06c708 added NOT NULL in addition to the PRIMARY KEY 2022-12-10 21:25:07 +02:00
Gani Georgiev 37bac5cc50 abstract rest.UploadedFile to allow loading local files 2022-12-10 16:47:45 +02:00
Gani Georgiev aa6eaa7319 optimized list.ToUniqueStringSlice 2022-12-10 12:08:59 +02:00
Gani Georgiev 68a9782c03 optimize DateTime scan and marshalization 2022-12-10 00:24:12 +02:00
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
701 changed files with 62332 additions and 18539 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username 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 ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 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 community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+5
View File
@@ -0,0 +1,5 @@
# Security
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
All reports will be promptly addressed, and you'll be credited accordingly.
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
with: with:
go-version: '>=1.19.0' go-version: '>=1.20.0'
# This step usually is not needed because the /ui/dist is pregenerated locally # This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts. # but its here to ensure that each release embeds the latest admin ui artifacts.
+5 -4
View File
@@ -6,12 +6,13 @@
# goreleaser builds folder # goreleaser builds folder
/.builds/ /.builds/
# examples app directories
pb_data
pb_public
# tests coverage # tests coverage
coverage.out coverage.out
# plaintask todo files # plaintask todo files
*.todo *.todo
# generated markdown previews
README.html
CHANGELOG.html
LICENSE.html
+1218
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ git clone https://github.com/your_username/pocketbase.git
## Making changes in the Go code ## Making changes in the Go code
PocketBase is a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package. PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables. The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
+1 -1
View File
@@ -1,5 +1,5 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2022, Gani Georgiev Copyright (c) 2022 - present, Gani Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy of this software Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction, and associated documentation files (the "Software"), to deal in the Software without restriction,
+2 -2
View File
@@ -2,8 +2,8 @@ lint:
golangci-lint run -c ./golangci.yml ./... golangci-lint run -c ./golangci.yml ./...
test: test:
go test -v --cover ./... go test ./... -v --cover
test-report: test-report:
go test -v --cover -coverprofile=coverage.out ./... go test ./... -v --cover -coverprofile=coverage.out
go tool cover -html=coverage.out go tool cover -html=coverage.out
+43 -12
View File
@@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<a href="https://pocketbase.io" target="_blank" rel="noopener"> <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> </a>
</p> </p>
@@ -19,8 +19,7 @@
**For documentation and examples, please visit https://pocketbase.io/docs.** **For documentation and examples, please visit https://pocketbase.io/docs.**
> ⚠️ Although the web API definitions 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. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
@@ -64,15 +63,15 @@ func main() {
app := pocketbase.New() app := pocketbase.New()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 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{ e.Router.AddRoute(echo.Route{
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/hello", Path: "/hello",
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
return c.String(200, "Hello world!") return c.String(200, "Hello world!")
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(), apis.ActivityLogger(app),
}, },
}) })
@@ -91,11 +90,33 @@ Running/building the application is the same as for any other Go program, aka. j
**PocketBase embeds SQLite, but doesn't require CGO.** **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). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively. If CGO is enabled (aka. `CGO_ENABLED=1`), 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).
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation. 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. To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo
2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
(_https://go.dev/doc/install/source#environment_)
4. Start the created executable by running `./base serve`.
The supported build targets by the non-cgo driver at the moment are:
```
darwin amd64
darwin arm64
freebsd amd64
freebsd arm64
linux 386
linux amd64
linux arm
linux arm64
linux ppc64le
linux riscv64
windows amd64
windows arm64
```
### Testing ### Testing
@@ -111,12 +132,13 @@ Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how t
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**. If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
All reports will be promptly addressed and you'll be credited accordingly. All reports will be promptly addressed, and you'll be credited accordingly.
## Contributing ## Contributing
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md). PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
You are free to do whatever you want with it, even offering it as a paid service.
You could help continuing its development by: You could help continuing its development by:
@@ -124,5 +146,14 @@ You could help continuing its development by:
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues) - [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us) - [Donate a small amount](https://pocketbase.io/support-us)
> Please 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)), PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
so all feature requests will be first aligned with what we need for Presentator v3.
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._
+157 -77
View File
@@ -9,20 +9,19 @@ import (
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens" "github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
) )
// BindAdminApi registers the admin api endpoints and the corresponding handlers. // bindAdminApi registers the admin api endpoints and the corresponding handlers.
func BindAdminApi(app core.App, rg *echo.Group) { func bindAdminApi(app core.App, rg *echo.Group) {
api := adminApi{app: app} api := adminApi{app: app}
subGroup := rg.Group("/admins", ActivityLogger(app)) subGroup := rg.Group("/admins", ActivityLogger(app))
subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly()) subGroup.POST("/auth-with-password", api.authWithPassword)
subGroup.POST("/request-password-reset", api.requestPasswordReset) subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset) 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.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app)) subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
subGroup.GET("/:id", api.view, RequireAdminAuth()) subGroup.GET("/:id", api.view, RequireAdminAuth())
@@ -37,14 +36,13 @@ type adminApi struct {
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error { func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin) token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
if tokenErr != nil { if tokenErr != nil {
return rest.NewBadRequestError("Failed to create auth token.", tokenErr) return NewBadRequestError("Failed to create auth token.", tokenErr)
} }
event := &core.AdminAuthEvent{ event := new(core.AdminAuthEvent)
HttpContext: c, event.HttpContext = c
Admin: admin, event.Admin = admin
Token: token, event.Token = token
}
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error { return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
return e.HttpContext.JSON(200, map[string]any{ return e.HttpContext.JSON(200, map[string]any{
@@ -54,62 +52,139 @@ 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) admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil { if admin == nil {
return rest.NewNotFoundError("Missing auth admin context.", nil) return NewNotFoundError("Missing auth admin context.", nil)
} }
return api.authResponse(c, admin) event := new(core.AdminAuthRefreshEvent)
event.HttpContext = c
event.Admin = admin
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
return api.authResponse(e.HttpContext, e.Admin)
})
if handlerErr == nil {
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
} }
func (api *adminApi) emailAuth(c echo.Context) error { func (api *adminApi) authWithPassword(c echo.Context) error {
form := forms.NewAdminLogin(api.app) form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr) return NewBadRequestError("An error occurred while loading the submitted data.", err)
} }
admin, submitErr := form.Submit() event := new(core.AdminAuthWithPasswordEvent)
if submitErr != nil { event.HttpContext = c
return rest.NewBadRequestError("Failed to authenticate.", submitErr) event.Password = form.Password
event.Identity = form.Identity
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
event.Admin = admin
return api.app.OnAdminBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
return api.authResponse(e.HttpContext, e.Admin)
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return api.authResponse(c, admin) return submitErr
} }
func (api *adminApi) requestPasswordReset(c echo.Context) error { func (api *adminApi) requestPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetRequest(api.app) form := forms.NewAdminPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err) return NewBadRequestError("An error occurred while loading the submitted data.", err)
} }
if err := form.Validate(); err != nil { 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 event := new(core.AdminRequestPasswordResetEvent)
// (prevents admins enumeration) event.HttpContext = c
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() { submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
log.Println(err) return func(Admin *models.Admin) error {
event.Admin = Admin
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Admin); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
})
} }
}) })
return c.NoContent(http.StatusNoContent) if submitErr == nil {
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
} }
func (api *adminApi) confirmPasswordReset(c echo.Context) error { func (api *adminApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetConfirm(api.app) form := forms.NewAdminPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil { if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr) return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
} }
admin, submitErr := form.Submit() event := new(core.AdminConfirmPasswordResetEvent)
if submitErr != nil { event.HttpContext = c
return rest.NewBadRequestError("Failed to set new password.", submitErr)
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
event.Admin = admin
return api.app.OnAdminBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
if err := next(e.Admin); err != nil {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return api.authResponse(c, admin) return submitErr
} }
func (api *adminApi) list(c echo.Context) error { func (api *adminApi) list(c echo.Context) error {
@@ -121,17 +196,16 @@ func (api *adminApi) list(c echo.Context) error {
result, err := search.NewProvider(fieldResolver). result, err := search.NewProvider(fieldResolver).
Query(api.app.Dao().AdminQuery()). Query(api.app.Dao().AdminQuery()).
ParseAndExec(c.QueryString(), &admins) ParseAndExec(c.QueryParams().Encode(), &admins)
if err != nil { if err != nil {
return rest.NewBadRequestError("", err) return NewBadRequestError("", err)
} }
event := &core.AdminsListEvent{ event := new(core.AdminsListEvent)
HttpContext: c, event.HttpContext = c
Admins: admins, event.Admins = admins
Result: result, event.Result = result
}
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error { return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result) return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -141,18 +215,17 @@ func (api *adminApi) list(c echo.Context) error {
func (api *adminApi) view(c echo.Context) error { func (api *adminApi) view(c echo.Context) error {
id := c.PathParam("id") id := c.PathParam("id")
if id == "" { if id == "" {
return rest.NewNotFoundError("", nil) return NewNotFoundError("", nil)
} }
admin, err := api.app.Dao().FindAdminById(id) admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil { if err != nil || admin == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
event := &core.AdminViewEvent{ event := new(core.AdminViewEvent)
HttpContext: c, event.HttpContext = c
Admin: admin, event.Admin = admin
}
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error { return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Admin) return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -166,20 +239,21 @@ func (api *adminApi) create(c echo.Context) error {
// load request // load request
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
} }
event := &core.AdminCreateEvent{ event := new(core.AdminCreateEvent)
HttpContext: c, event.HttpContext = c
Admin: admin, event.Admin = admin
}
// create the admin // create the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func() error { return func(m *models.Admin) error {
event.Admin = m
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error { return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil { if err := next(e.Admin); err != nil {
return rest.NewBadRequestError("Failed to create admin.", err) return NewBadRequestError("Failed to create admin.", err)
} }
return e.HttpContext.JSON(http.StatusOK, e.Admin) return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -188,7 +262,9 @@ func (api *adminApi) create(c echo.Context) error {
}) })
if submitErr == nil { if submitErr == nil {
api.app.OnAdminAfterCreateRequest().Trigger(event) if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return submitErr return submitErr
@@ -197,32 +273,33 @@ func (api *adminApi) create(c echo.Context) error {
func (api *adminApi) update(c echo.Context) error { func (api *adminApi) update(c echo.Context) error {
id := c.PathParam("id") id := c.PathParam("id")
if id == "" { if id == "" {
return rest.NewNotFoundError("", nil) return NewNotFoundError("", nil)
} }
admin, err := api.app.Dao().FindAdminById(id) admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil { if err != nil || admin == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
form := forms.NewAdminUpsert(api.app, admin) form := forms.NewAdminUpsert(api.app, admin)
// load request // load request
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
} }
event := &core.AdminUpdateEvent{ event := new(core.AdminUpdateEvent)
HttpContext: c, event.HttpContext = c
Admin: admin, event.Admin = admin
}
// update the admin // update the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func() error { return func(m *models.Admin) error {
event.Admin = m
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error { return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil { if err := next(e.Admin); err != nil {
return rest.NewBadRequestError("Failed to update admin.", err) return NewBadRequestError("Failed to update admin.", err)
} }
return e.HttpContext.JSON(http.StatusOK, e.Admin) return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -231,7 +308,9 @@ func (api *adminApi) update(c echo.Context) error {
}) })
if submitErr == nil { if submitErr == nil {
api.app.OnAdminAfterUpdateRequest().Trigger(event) if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return submitErr return submitErr
@@ -240,29 +319,30 @@ func (api *adminApi) update(c echo.Context) error {
func (api *adminApi) delete(c echo.Context) error { func (api *adminApi) delete(c echo.Context) error {
id := c.PathParam("id") id := c.PathParam("id")
if id == "" { if id == "" {
return rest.NewNotFoundError("", nil) return NewNotFoundError("", nil)
} }
admin, err := api.app.Dao().FindAdminById(id) admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil { if err != nil || admin == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
event := &core.AdminDeleteEvent{ event := new(core.AdminDeleteEvent)
HttpContext: c, event.HttpContext = c
Admin: admin, event.Admin = admin
}
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error { handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil { 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) return e.HttpContext.NoContent(http.StatusNoContent)
}) })
if handlerErr == nil { if handlerErr == nil {
api.app.OnAdminAfterDeleteRequest().Trigger(event) if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return handlerErr return handlerErr
+219 -138
View File
@@ -14,55 +14,79 @@ import (
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
) )
func TestAdminAuth(t *testing.T) { func TestAdminAuthWithPassword(t *testing.T) {
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "empty data", Name: "empty data",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/auth-via-email", Url: "/api/admins/auth-with-password",
Body: strings.NewReader(``), Body: strings.NewReader(``),
ExpectedStatus: 400, 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", Name: "invalid data",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/auth-via-email", Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{`), Body: strings.NewReader(`{`),
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "wrong email/password", Name: "wrong email",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/auth-via-email", Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`), Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
},
}, },
{ {
Name: "valid email/password (already authorized)", Name: "wrong password",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/auth-via-email", Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`), Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`}, ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
},
}, },
{ {
Name: "valid email/password (guest)", Name: "valid email/password (guest)",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/auth-via-email", Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`), Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`, `"token":`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1, "OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
"OnAdminAuthRequest": 1,
},
},
{
Name: "valid email/password (already authorized)",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
"OnAdminBeforeAuthWithPasswordRequest": 1,
"OnAdminAfterAuthWithPasswordRequest": 1,
"OnAdminAuthRequest": 1,
}, },
}, },
} }
@@ -106,10 +130,12 @@ func TestAdminRequestPasswordReset(t *testing.T) {
Delay: 100 * time.Millisecond, Delay: 100 * time.Millisecond,
ExpectedStatus: 204, ExpectedStatus: 204,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1, "OnModelAfterUpdate": 1,
"OnMailerBeforeAdminResetPasswordSend": 1, "OnMailerBeforeAdminResetPasswordSend": 1,
"OnMailerAfterAdminResetPasswordSend": 1, "OnMailerAfterAdminResetPasswordSend": 1,
"OnAdminBeforeRequestPasswordResetRequest": 1,
"OnAdminAfterRequestPasswordResetRequest": 1,
}, },
}, },
{ {
@@ -158,27 +184,44 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "expired token", Name: "expired token",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset", Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA","password":"1234567890","passwordConfirm":"1234567890"}`), Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
"password":"1234567890",
"passwordConfirm":"1234567890"
}`),
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`}, ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`},
}, },
{ {
Name: "valid token", Name: "valid token + invalid password",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset", Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw","password":"1234567890","passwordConfirm":"1234567890"}`), Body: strings.NewReader(`{
ExpectedStatus: 200, "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
ExpectedContent: []string{ "password":"123456",
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, "passwordConfirm":"123456"
`"token":`, }`),
}, 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{ ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1, "OnModelAfterUpdate": 1,
"OnAdminAuthRequest": 1, "OnAdminBeforeConfirmPasswordResetRequest": 1,
"OnAdminAfterConfirmPasswordResetRequest": 1,
}, },
}, },
} }
@@ -193,34 +236,46 @@ func TestAdminRefresh(t *testing.T) {
{ {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/refresh", Url: "/api/admins/auth-refresh",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as user",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/refresh", Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as admin", Name: "authorized as admin (expired token)",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins/refresh", Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{ 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, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`, `"token":`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnAdminAuthRequest": 1, "OnAdminAuthRequest": 1,
"OnAdminBeforeAuthRefreshRequest": 1,
"OnAdminAfterAuthRefreshRequest": 1,
}, },
}, },
} }
@@ -244,7 +299,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins", Url: "/api/admins",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -254,16 +309,17 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins", Url: "/api/admins",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"page":1`, `"page":1`,
`"perPage":30`, `"perPage":30`,
`"totalItems":2`, `"totalItems":3`,
`"items":[{`, `"items":[{`,
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, `"id":"sywbhecnh46rhm0"`,
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, `"id":"sbmbsdb40jyxf7h"`,
`"id":"9q2trqumvlyr3bd"`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1, "OnAdminsListRequest": 1,
@@ -274,15 +330,19 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins?page=2&perPage=1&sort=-created", Url: "/api/admins?page=2&perPage=1&sort=-created",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"page":2`, `"page":2`,
`"perPage":1`, `"perPage":1`,
`"totalItems":2`, `"totalItems":3`,
`"items":[{`, `"items":[{`,
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, `"id":"sbmbsdb40jyxf7h"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1, "OnAdminsListRequest": 1,
@@ -293,7 +353,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins?filter=invalidfield~'test2'", Url: "/api/admins?filter=invalidfield~'test2'",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -301,9 +361,9 @@ func TestAdminsList(t *testing.T) {
{ {
Name: "authorized as admin + valid filter", Name: "authorized as admin + valid filter",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins?filter=email~'test2'", Url: "/api/admins?filter=email~'test3'",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
@@ -311,7 +371,11 @@ func TestAdminsList(t *testing.T) {
`"perPage":30`, `"perPage":30`,
`"totalItems":1`, `"totalItems":1`,
`"items":[{`, `"items":[{`,
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, `"id":"9q2trqumvlyr3bd"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1, "OnAdminsListRequest": 1,
@@ -329,36 +393,26 @@ func TestAdminView(t *testing.T) {
{ {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as user",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, 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", Name: "authorized as admin + nonexisting admin id",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c", Url: "/api/admins/nonexisting",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 404, ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -366,13 +420,17 @@ func TestAdminView(t *testing.T) {
{ {
Name: "authorized as admin + existing admin id", Name: "authorized as admin + existing admin id",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, `"id":"sbmbsdb40jyxf7h"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnAdminViewRequest": 1, "OnAdminViewRequest": 1,
@@ -390,36 +448,26 @@ func TestAdminDelete(t *testing.T) {
{ {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodDelete, Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as user",
Method: http.MethodDelete, Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as admin + invalid admin id", Name: "authorized as admin + missing admin id",
Method: http.MethodDelete, Method: http.MethodDelete,
Url: "/api/admins/invalid", Url: "/api/admins/missing",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
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",
}, },
ExpectedStatus: 404, ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -427,9 +475,9 @@ func TestAdminDelete(t *testing.T) {
{ {
Name: "authorized as admin + existing admin id", Name: "authorized as admin + existing admin id",
Method: http.MethodDelete, Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 204, ExpectedStatus: 204,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -442,15 +490,15 @@ func TestAdminDelete(t *testing.T) {
{ {
Name: "authorized as admin - try to delete the only remaining admin", Name: "authorized as admin - try to delete the only remaining admin",
Method: http.MethodDelete, Method: http.MethodDelete,
Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", Url: "/api/admins/sywbhecnh46rhm0",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins except the authorized one // delete all admins except the authorized one
adminModel := &models.Admin{} adminModel := &models.Admin{}
_, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{ _, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{
"id": "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", "id": "sywbhecnh46rhm0",
})).Execute() })).Execute()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -508,7 +556,7 @@ func TestAdminCreate(t *testing.T) {
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins", Url: "/api/admins",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -519,7 +567,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins", Url: "/api/admins",
Body: strings.NewReader(``), Body: strings.NewReader(``),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`}, ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
@@ -530,7 +578,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins", Url: "/api/admins",
Body: strings.NewReader(`{`), Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -539,20 +587,36 @@ func TestAdminCreate(t *testing.T) {
Name: "authorized as admin + invalid data", Name: "authorized as admin + invalid data",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins", 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{ 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"`,
`"email":{"code":"validation_admin_email_exists"`,
`"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
}, },
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."}}`},
}, },
{ {
Name: "authorized as admin + valid data", Name: "authorized as admin + valid data",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/admins", 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{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
@@ -560,6 +624,12 @@ func TestAdminCreate(t *testing.T) {
`"email":"testnew@example.com"`, `"email":"testnew@example.com"`,
`"avatar":3`, `"avatar":3`,
}, },
NotExpectedContent: []string{
`"password"`,
`"passwordConfirm"`,
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1, "OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1, "OnModelAfterCreate": 1,
@@ -579,38 +649,27 @@ func TestAdminUpdate(t *testing.T) {
{ {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as user",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as admin + invalid admin id", Name: "authorized as admin + missing admin",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/admins/invalid", Url: "/api/admins/missing",
Body: strings.NewReader(``), Body: strings.NewReader(``),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
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",
}, },
ExpectedStatus: 404, ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -618,14 +677,14 @@ func TestAdminUpdate(t *testing.T) {
{ {
Name: "authorized as admin + empty data", Name: "authorized as admin + empty data",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(``), Body: strings.NewReader(``),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, `"id":"sbmbsdb40jyxf7h"`,
`"email":"test2@example.com"`, `"email":"test2@example.com"`,
`"avatar":2`, `"avatar":2`,
}, },
@@ -639,10 +698,10 @@ func TestAdminUpdate(t *testing.T) {
{ {
Name: "authorized as admin + invalid formatted data", Name: "authorized as admin + invalid formatted data",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{`), Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -650,27 +709,49 @@ func TestAdminUpdate(t *testing.T) {
{ {
Name: "authorized as admin + invalid data", Name: "authorized as admin + invalid data",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
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{ 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"`,
`"email":{"code":"validation_admin_email_exists"`,
`"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
}, },
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."}}`},
}, },
{ {
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`), Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567891",
"passwordConfirm":"1234567891",
"avatar":5
}`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, `"id":"sbmbsdb40jyxf7h"`,
`"email":"testnew@example.com"`, `"email":"testnew@example.com"`,
`"avatar":5`, `"avatar":5`,
}, },
NotExpectedContent: []string{
`"password"`,
`"passwordConfirm"`,
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1, "OnModelAfterUpdate": 1,
@@ -1,4 +1,4 @@
package rest package apis
import ( import (
"net/http" "net/http"
@@ -8,7 +8,7 @@ import (
"github.com/pocketbase/pocketbase/tools/inflector" "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 { type ApiError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@@ -23,6 +23,7 @@ func (e *ApiError) Error() string {
return e.Message return e.Message
} }
// RawData returns the unformatted error data (could be an internal error, text, etc.)
func (e *ApiError) RawData() any { func (e *ApiError) RawData() any {
return e.rawData return e.rawData
} }
@@ -1,4 +1,4 @@
package rest_test package apis_test
import ( import (
"encoding/json" "encoding/json"
@@ -6,11 +6,11 @@ import (
"testing" "testing"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/rest" "github.com/pocketbase/pocketbase/apis"
) )
func TestNewApiErrorWithRawData(t *testing.T) { func TestNewApiErrorWithRawData(t *testing.T) {
e := rest.NewApiError( e := apis.NewApiError(
300, 300,
"message_test", "message_test",
"rawData_test", "rawData_test",
@@ -33,7 +33,7 @@ func TestNewApiErrorWithRawData(t *testing.T) {
} }
func TestNewApiErrorWithValidationData(t *testing.T) { func TestNewApiErrorWithValidationData(t *testing.T) {
e := rest.NewApiError( e := apis.NewApiError(
300, 300,
"message_test", "message_test",
validation.Errors{ validation.Errors{
@@ -77,7 +77,7 @@ func TestNewNotFoundError(t *testing.T) {
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
e := rest.NewNotFoundError(scenario.message, scenario.data) e := apis.NewNotFoundError(scenario.message, scenario.data)
result, _ := json.Marshal(e) result, _ := json.Marshal(e)
if string(result) != scenario.expected { if string(result) != scenario.expected {
@@ -98,7 +98,7 @@ func TestNewBadRequestError(t *testing.T) {
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
e := rest.NewBadRequestError(scenario.message, scenario.data) e := apis.NewBadRequestError(scenario.message, scenario.data)
result, _ := json.Marshal(e) result, _ := json.Marshal(e)
if string(result) != scenario.expected { if string(result) != scenario.expected {
@@ -119,7 +119,7 @@ func TestNewForbiddenError(t *testing.T) {
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
e := rest.NewForbiddenError(scenario.message, scenario.data) e := apis.NewForbiddenError(scenario.message, scenario.data)
result, _ := json.Marshal(e) result, _ := json.Marshal(e)
if string(result) != scenario.expected { if string(result) != scenario.expected {
@@ -140,7 +140,7 @@ func TestNewUnauthorizedError(t *testing.T) {
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
e := rest.NewUnauthorizedError(scenario.message, scenario.data) e := apis.NewUnauthorizedError(scenario.message, scenario.data)
result, _ := json.Marshal(e) result, _ := json.Marshal(e)
if string(result) != scenario.expected { if string(result) != scenario.expected {
+58 -34
View File
@@ -2,6 +2,7 @@
package apis package apis
import ( import (
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log" "log"
@@ -13,7 +14,6 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware" "github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/ui" "github.com/pocketbase/pocketbase/ui"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
@@ -26,6 +26,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
e := echo.New() e := echo.New()
e.Debug = app.IsDebug() e.Debug = app.IsDebug()
// configure a custom router
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
return echo.NewRouter(echo.RouterConfig{
UnescapePathParamValues: true,
})
})
// default middlewares // default middlewares
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{ e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
Skipper: func(c echo.Context) bool { Skipper: func(c echo.Context) bool {
@@ -43,7 +50,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
return return
} }
var apiErr *rest.ApiError var apiErr *ApiError
switch v := err.(type) { switch v := err.(type) {
case *echo.HTTPError: case *echo.HTTPError:
@@ -51,8 +58,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
log.Println(v.Internal) log.Println(v.Internal)
} }
msg := fmt.Sprintf("%v", v.Message) msg := fmt.Sprintf("%v", v.Message)
apiErr = rest.NewApiError(v.Code, msg, v) apiErr = NewApiError(v.Code, msg, v)
case *rest.ApiError: case *ApiError:
if app.IsDebug() && v.RawData() != nil { if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData()) log.Println(v.RawData())
} }
@@ -61,22 +68,29 @@ func InitApi(app core.App) (*echo.Echo, error) {
if err != nil && app.IsDebug() { if err != nil && app.IsDebug() {
log.Println(err) log.Println(err)
} }
apiErr = rest.NewBadRequestError("", err) apiErr = NewBadRequestError("", err)
} }
// Send response event := new(core.ApiErrorEvent)
var cErr error event.HttpContext = c
if c.Request().Method == http.MethodHead { event.Error = apiErr
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
// @see https://github.com/labstack/echo/issues/608 // @see https://github.com/labstack/echo/issues/608
cErr = c.NoContent(apiErr.Code) if e.HttpContext.Request().Method == http.MethodHead {
} else { return e.HttpContext.NoContent(apiErr.Code)
cErr = c.JSON(apiErr.Code, apiErr) }
}
return e.HttpContext.JSON(apiErr.Code, apiErr)
})
// truly rare case; eg. client already disconnected // truly rare case; eg. client already disconnected
if cErr != nil && app.IsDebug() { if hookErr != nil && app.IsDebug() {
log.Println(cErr) log.Println(hookErr)
} }
app.OnAfterApiError().Trigger(event)
} }
// admin ui routes // admin ui routes
@@ -84,14 +98,15 @@ func InitApi(app core.App) (*echo.Echo, error) {
// default routes // default routes
api := e.Group("/api") api := e.Group("/api")
BindSettingsApi(app, api) bindSettingsApi(app, api)
BindAdminApi(app, api) bindAdminApi(app, api)
BindUserApi(app, api) bindCollectionApi(app, api)
BindCollectionApi(app, api) bindRecordCrudApi(app, api)
BindRecordApi(app, api) bindRecordAuthApi(app, api)
BindFileApi(app, api) bindFileApi(app, api)
BindRealtimeApi(app, api) bindRealtimeApi(app, api)
BindLogsApi(app, api) bindLogsApi(app, api)
bindHealthApi(app, api)
// trigger the custom BeforeServe hook for the created api router // trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes // allowing users to further adjust its options or register new routes
@@ -114,22 +129,31 @@ func InitApi(app core.App) (*echo.Echo, error) {
// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler` // StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware. // 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 // @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 { return func(c echo.Context) error {
p := c.PathParam("*") p := c.PathParam("*")
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
tmpPath, err := url.PathUnescape(p) // escape url path
if err != nil { tmpPath, err := url.PathUnescape(p)
return fmt.Errorf("failed to unescape path variable: %w", err) if err != nil {
} return fmt.Errorf("failed to unescape path variable: %w", err)
p = tmpPath
} }
p = tmpPath
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid // 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, "/"))) 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
} }
} }
@@ -139,7 +163,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
e.GET( e.GET(
strings.TrimRight(trailedAdminPath, "/"), strings.TrimRight(trailedAdminPath, "/"),
func(c echo.Context) error { func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath) return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
}, },
) )
@@ -200,12 +224,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
if totalAdmins == 0 && !hasInstallerParam { if totalAdmins == 0 && !hasInstallerParam {
// redirect to the installer page // redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#") return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
} }
if totalAdmins != 0 && hasInstallerParam { if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page // clear the installer param
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/") return c.Redirect(http.StatusTemporaryRedirect, "?")
} }
return next(c) return next(c)
+18 -2
View File
@@ -6,8 +6,8 @@ import (
"testing" "testing"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/rest"
) )
func Test404(t *testing.T) { func Test404(t *testing.T) {
@@ -66,6 +66,22 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"test123"}, ExpectedContent: []string{"test123"},
}, },
{
Name: "custom route with url encoded parameter",
Method: http.MethodGet,
Url: "/a%2Bb%2Bc",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/:param",
Handler: func(c echo.Context) error {
return c.String(200, c.PathParam("param"))
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"a+b+c"},
},
{ {
Name: "route with HTTPError", Name: "route with HTTPError",
Method: http.MethodGet, Method: http.MethodGet,
@@ -91,7 +107,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api-error", Path: "/api-error",
Handler: func(c echo.Context) 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"))
}, },
}) })
}, },
+62 -56
View File
@@ -1,18 +1,18 @@
package apis package apis
import ( import (
"log"
"net/http" "net/http"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
) )
// BindCollectionApi registers the collection api endpoints and the corresponding handlers. // bindCollectionApi registers the collection api endpoints and the corresponding handlers.
func BindCollectionApi(app core.App, rg *echo.Group) { func bindCollectionApi(app core.App, rg *echo.Group) {
api := collectionApi{app: app} api := collectionApi{app: app}
subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth()) subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth())
@@ -30,24 +30,23 @@ type collectionApi struct {
func (api *collectionApi) list(c echo.Context) error { func (api *collectionApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver( fieldResolver := search.NewSimpleFieldResolver(
"id", "created", "updated", "name", "system", "id", "created", "updated", "name", "system", "type",
) )
collections := []*models.Collection{} collections := []*models.Collection{}
result, err := search.NewProvider(fieldResolver). result, err := search.NewProvider(fieldResolver).
Query(api.app.Dao().CollectionQuery()). Query(api.app.Dao().CollectionQuery()).
ParseAndExec(c.QueryString(), &collections) ParseAndExec(c.QueryParams().Encode(), &collections)
if err != nil { if err != nil {
return rest.NewBadRequestError("", err) return NewBadRequestError("", err)
} }
event := &core.CollectionsListEvent{ event := new(core.CollectionsListEvent)
HttpContext: c, event.HttpContext = c
Collections: collections, event.Collections = collections
Result: result, event.Result = result
}
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error { return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result) return e.HttpContext.JSON(http.StatusOK, e.Result)
@@ -57,13 +56,12 @@ func (api *collectionApi) list(c echo.Context) error {
func (api *collectionApi) view(c echo.Context) error { func (api *collectionApi) view(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil { if err != nil || collection == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
event := &core.CollectionViewEvent{ event := new(core.CollectionViewEvent)
HttpContext: c, event.HttpContext = c
Collection: collection, event.Collection = collection
}
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error { return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Collection) return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -77,20 +75,21 @@ func (api *collectionApi) create(c echo.Context) error {
// load request // load request
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
} }
event := &core.CollectionCreateEvent{ event := new(core.CollectionCreateEvent)
HttpContext: c, event.HttpContext = c
Collection: collection, event.Collection = collection
}
// create the collection // create the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func() error { return func(m *models.Collection) error {
event.Collection = m
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error { return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if err := next(); err != nil { if err := next(e.Collection); err != nil {
return rest.NewBadRequestError("Failed to create the collection.", err) return NewBadRequestError("Failed to create the collection.", err)
} }
return e.HttpContext.JSON(http.StatusOK, e.Collection) return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -99,7 +98,9 @@ func (api *collectionApi) create(c echo.Context) error {
}) })
if submitErr == nil { if submitErr == nil {
api.app.OnCollectionAfterCreateRequest().Trigger(event) if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return submitErr return submitErr
@@ -108,27 +109,28 @@ func (api *collectionApi) create(c echo.Context) error {
func (api *collectionApi) update(c echo.Context) error { func (api *collectionApi) update(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil { if err != nil || collection == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
form := forms.NewCollectionUpsert(api.app, collection) form := forms.NewCollectionUpsert(api.app, collection)
// load request // load request
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
} }
event := &core.CollectionUpdateEvent{ event := new(core.CollectionUpdateEvent)
HttpContext: c, event.HttpContext = c
Collection: collection, event.Collection = collection
}
// update the collection // update the collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func() error { return func(m *models.Collection) error {
event.Collection = m
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error { return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if err := next(); err != nil { if err := next(e.Collection); err != nil {
return rest.NewBadRequestError("Failed to update the collection.", err) return NewBadRequestError("Failed to update the collection.", err)
} }
return e.HttpContext.JSON(http.StatusOK, e.Collection) return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -137,7 +139,9 @@ func (api *collectionApi) update(c echo.Context) error {
}) })
if submitErr == nil { if submitErr == nil {
api.app.OnCollectionAfterUpdateRequest().Trigger(event) if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return submitErr return submitErr
@@ -146,24 +150,25 @@ func (api *collectionApi) update(c echo.Context) error {
func (api *collectionApi) delete(c echo.Context) error { func (api *collectionApi) delete(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil { if err != nil || collection == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
event := &core.CollectionDeleteEvent{ event := new(core.CollectionDeleteEvent)
HttpContext: c, event.HttpContext = c
Collection: collection, event.Collection = collection
}
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error { handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil { 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) return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
} }
return e.HttpContext.NoContent(http.StatusNoContent) return e.HttpContext.NoContent(http.StatusNoContent)
}) })
if handlerErr == nil { if handlerErr == nil {
api.app.OnCollectionAfterDeleteRequest().Trigger(event) if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return handlerErr return handlerErr
@@ -174,22 +179,21 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
// load request data // load request data
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
} }
event := &core.CollectionsImportEvent{ event := new(core.CollectionsImportEvent)
HttpContext: c, event.HttpContext = c
Collections: form.Collections, event.Collections = form.Collections
}
// import collections // import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func() error { return func(imports []*models.Collection) error {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error { event.Collections = imports
form.Collections = e.Collections // ensures that the form always has the latest changes
if err := next(); err != nil { return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
return rest.NewBadRequestError("Failed to import the submitted collections.", err) if err := next(e.Collections); err != nil {
return NewBadRequestError("Failed to import the submitted collections.", err)
} }
return e.HttpContext.NoContent(http.StatusNoContent) return e.HttpContext.NoContent(http.StatusNoContent)
@@ -198,7 +202,9 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
}) })
if submitErr == nil { if submitErr == nil {
api.app.OnCollectionsAfterImportRequest().Trigger(event) if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return submitErr return submitErr
+660 -134
View File
File diff suppressed because it is too large. Load diff
+43 -23
View File
@@ -1,22 +1,24 @@
package apis package apis
import ( import (
"fmt"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list" "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"} var defaultThumbSizes = []string{"100x100"}
// BindFileApi registers the file api endpoints and the corresponding handlers. // bindFileApi registers the file api endpoints and the corresponding handlers.
func BindFileApi(app core.App, rg *echo.Group) { func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app} api := fileApi{app: app}
subGroup := rg.Group("/files", ActivityLogger(app)) subGroup := rg.Group("/files", ActivityLogger(app))
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app)) subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
} }
@@ -27,34 +29,46 @@ type fileApi struct {
func (api *fileApi) download(c echo.Context) error { func (api *fileApi) download(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection) collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil { if collection == nil {
return rest.NewNotFoundError("", nil) return NewNotFoundError("", nil)
} }
recordId := c.PathParam("recordId") recordId := c.PathParam("recordId")
if 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 { if err != nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
filename := c.PathParam("filename") filename := c.PathParam("filename")
fileField := record.FindFileFieldByFile(filename) fileField := record.FindFileFieldByFile(filename)
if fileField == nil { if fileField == nil {
return rest.NewNotFoundError("", nil) return NewNotFoundError("", nil)
} }
options, _ := fileField.Options.(*schema.FileOptions) options, _ := fileField.Options.(*schema.FileOptions)
baseFilesPath := record.BaseFilesPath()
// fetch the original view file field related record
if collection.IsView() {
fileRecord, err := api.app.Dao().FindRecordByViewFile(collection.Id, fileField.Name, filename)
if err != nil {
return NewNotFoundError("", fmt.Errorf("Failed to fetch view file field record: %w", err))
}
baseFilesPath = fileRecord.BaseFilesPath()
}
fs, err := api.app.NewFilesystem() fs, err := api.app.NewFilesystem()
if err != nil { if err != nil {
return rest.NewBadRequestError("Filesystem initialization failure.", err) return NewBadRequestError("Filesystem initialization failure.", err)
} }
defer fs.Close() defer fs.Close()
originalPath := record.BaseFilesPath() + "/" + filename originalPath := baseFilesPath + "/" + filename
servedPath := originalPath servedPath := originalPath
servedName := filename servedName := filename
@@ -64,14 +78,14 @@ func (api *fileApi) download(c echo.Context) error {
// extract the original file meta attributes and check it existence // extract the original file meta attributes and check it existence
oAttrs, oAttrsErr := fs.Attributes(originalPath) oAttrs, oAttrsErr := fs.Attributes(originalPath)
if oAttrsErr != nil { if oAttrsErr != nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
// check if it is an image // check if it is an image
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) { if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
// add thumb size as file suffix // add thumb size as file suffix
servedName = thumbSize + "_" + filename servedName = thumbSize + "_" + filename
servedPath = record.BaseFilesPath() + "/thumbs_" + filename + "/" + servedName servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
// check if the thumb exists: // check if the thumb exists:
// - if doesn't exist - create a new thumb with the specified thumb size // - if doesn't exist - create a new thumb with the specified thumb size
@@ -85,18 +99,24 @@ func (api *fileApi) download(c echo.Context) error {
} }
} }
event := &core.FileDownloadEvent{ event := new(core.FileDownloadEvent)
HttpContext: c, event.HttpContext = c
Record: record, event.Collection = collection
Collection: collection, event.Record = record
FileField: fileField, event.FileField = fileField
ServedPath: servedPath, event.ServedPath = servedPath
ServedName: servedName, event.ServedName = servedName
}
// clickjacking shouldn't be a concern when serving uploaded files,
// so it safe to unset the global X-Frame-Options to allow files embedding
// (note: it is out of the hook to allow users to customize the behavior)
c.Response().Header().Del("X-Frame-Options")
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error { return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil { res := e.HttpContext.Response()
return rest.NewNotFoundError("", err) req := e.HttpContext.Request()
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
return NewNotFoundError("", err)
} }
return nil return nil
+29 -20
View File
@@ -14,14 +14,15 @@ import (
func TestFileDownload(t *testing.T) { func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0) _, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/" 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") testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt")
testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50_4881bdef-06b4-4dea-8d97-6125ad242677.png") testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png")
testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50t_4881bdef-06b4-4dea-8d97-6125ad242677.png") testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png")
testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50b_4881bdef-06b4-4dea-8d97-6125ad242677.png") testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png")
testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50f_4881bdef-06b4-4dea-8d97-6125ad242677.png") testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png")
testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/0x50_4881bdef-06b4-4dea-8d97-6125ad242677.png") testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png")
testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x0_4881bdef-06b4-4dea-8d97-6125ad242677.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) testFile, fileErr := os.ReadFile(testFilePath)
if fileErr != nil { if fileErr != nil {
@@ -67,28 +68,28 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "missing collection", Name: "missing collection",
Method: http.MethodGet, 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, ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "missing record", Name: "missing record",
Method: http.MethodGet, 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, ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "missing file", Name: "missing file",
Method: http.MethodGet, 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, ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "existing image", Name: "existing image",
Method: http.MethodGet, 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, ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)}, ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -98,7 +99,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing image - missing thumb (should fallback to the original)", Name: "existing image - missing thumb (should fallback to the original)",
Method: http.MethodGet, 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, ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)}, ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -108,7 +109,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing image - existing thumb (crop center)", Name: "existing image - existing thumb (crop center)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50", Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropCenter)}, ExpectedContent: []string{string(testThumbCropCenter)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -118,7 +119,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing image - existing thumb (crop top)", Name: "existing image - existing thumb (crop top)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50t", Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropTop)}, ExpectedContent: []string{string(testThumbCropTop)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -128,7 +129,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing image - existing thumb (crop bottom)", Name: "existing image - existing thumb (crop bottom)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50b", Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropBottom)}, ExpectedContent: []string{string(testThumbCropBottom)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -138,7 +139,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing image - existing thumb (fit)", Name: "existing image - existing thumb (fit)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50f", Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbFit)}, ExpectedContent: []string{string(testThumbFit)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -148,7 +149,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing image - existing thumb (zero width)", Name: "existing image - existing thumb (zero width)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=0x50", Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroWidth)}, ExpectedContent: []string{string(testThumbZeroWidth)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -158,7 +159,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing image - existing thumb (zero height)", Name: "existing image - existing thumb (zero height)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x0", Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroHeight)}, ExpectedContent: []string{string(testThumbZeroHeight)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -168,7 +169,7 @@ func TestFileDownload(t *testing.T) {
{ {
Name: "existing non image file - thumb parameter should be ignored", Name: "existing non image file - thumb parameter should be ignored",
Method: http.MethodGet, 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, ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)}, ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -178,6 +179,14 @@ func TestFileDownload(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
// clone for the HEAD test (the same as the original scenario but without body)
head := scenario
head.Method = http.MethodHead
head.Name = ("(HEAD) " + scenario.Name)
head.ExpectedContent = nil
head.Test(t)
// regular request test
scenario.Test(t) scenario.Test(t)
} }
} }
+30
View File
@@ -0,0 +1,30 @@
package apis
import (
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
)
// bindHealthApi registers the health api endpoint.
func bindHealthApi(app core.App, rg *echo.Group) {
api := healthApi{app: app}
subGroup := rg.Group("/health")
subGroup.GET("", api.healthCheck)
}
type healthApi struct {
app core.App
}
// healthCheck returns a 200 OK response if the server is healthy.
func (api *healthApi) healthCheck(c echo.Context) error {
payload := map[string]any{
"code": http.StatusOK,
"message": "API is healthy.",
}
return c.JSON(http.StatusOK, payload)
}
+26
View File
@@ -0,0 +1,26 @@
package apis_test
import (
"net/http"
"testing"
"github.com/pocketbase/pocketbase/tests"
)
func TestHealthAPI(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "health status returns 200",
Method: http.MethodGet,
Url: "/api/health",
ExpectedStatus: 200,
ExpectedContent: []string{
`"code":200`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+8 -9
View File
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
) )
// BindLogsApi registers the request logs api endpoints. // bindLogsApi registers the request logs api endpoints.
func BindLogsApi(app core.App, rg *echo.Group) { func bindLogsApi(app core.App, rg *echo.Group) {
api := logsApi{app: app} api := logsApi{app: app}
subGroup := rg.Group("/logs", RequireAdminAuth()) subGroup := rg.Group("/logs", RequireAdminAuth())
@@ -36,10 +35,10 @@ func (api *logsApi) requestsList(c echo.Context) error {
result, err := search.NewProvider(fieldResolver). result, err := search.NewProvider(fieldResolver).
Query(api.app.LogsDao().RequestQuery()). Query(api.app.LogsDao().RequestQuery()).
ParseAndExec(c.QueryString(), &[]*models.Request{}) ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
if err != nil { if err != nil {
return rest.NewBadRequestError("", err) return NewBadRequestError("", err)
} }
return c.JSON(http.StatusOK, result) return c.JSON(http.StatusOK, result)
@@ -55,13 +54,13 @@ func (api *logsApi) requestsStats(c echo.Context) error {
var err error var err error
expr, err = search.FilterData(filter).BuildExpr(fieldResolver) expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
if err != nil { if err != nil {
return rest.NewBadRequestError("Invalid filter format.", err) return NewBadRequestError("Invalid filter format.", err)
} }
} }
stats, err := api.app.LogsDao().RequestsStats(expr) stats, err := api.app.LogsDao().RequestsStats(expr)
if err != nil { 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) 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 { func (api *logsApi) requestView(c echo.Context) error {
id := c.PathParam("id") id := c.PathParam("id")
if id == "" { if id == "" {
return rest.NewNotFoundError("", nil) return NewNotFoundError("", nil)
} }
request, err := api.app.LogsDao().FindRequestById(id) request, err := api.app.LogsDao().FindRequestById(id)
if err != nil || request == nil { if err != nil || request == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
} }
return c.JSON(http.StatusOK, request) return c.JSON(http.StatusOK, request)
+14 -14
View File
@@ -18,11 +18,11 @@ func TestRequestsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as auth record",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests", Url: "/api/logs/requests",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -32,7 +32,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests", Url: "/api/logs/requests",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil { if err := tests.MockRequestLogsData(app); err != nil {
@@ -54,7 +54,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests?filter=status>200", Url: "/api/logs/requests?filter=status>200",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil { if err := tests.MockRequestLogsData(app); err != nil {
@@ -87,11 +87,11 @@ func TestRequestView(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as auth record",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -101,7 +101,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91", Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil { if err := tests.MockRequestLogsData(app); err != nil {
@@ -116,7 +116,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil { if err := tests.MockRequestLogsData(app); err != nil {
@@ -145,11 +145,11 @@ func TestRequestsStats(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as auth record",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/stats", Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -159,7 +159,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/stats", Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil { if err := tests.MockRequestLogsData(app); err != nil {
@@ -168,7 +168,7 @@ func TestRequestsStats(t *testing.T) {
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ 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,7 +176,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/logs/requests/stats?filter=status>200", Url: "/api/logs/requests/stats?filter=status>200",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil { if err := tests.MockRequestLogsData(app); err != nil {
@@ -185,7 +185,7 @@ func TestRequestsStats(t *testing.T) {
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`[{"total":1,"date":"2022-05-02 10:00:00.000"}]`, `[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
}, },
}, },
} }
+135 -77
View File
@@ -11,30 +11,32 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest" "github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
// Common request context keys used by the middlewares and api handlers. // Common request context keys used by the middlewares and api handlers.
const ( const (
ContextUserKey string = "user"
ContextAdminKey string = "admin" ContextAdminKey string = "admin"
ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection" ContextCollectionKey string = "collection"
) )
// RequireGuestOnly middleware requires a request to NOT have a valid // 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 { func RequireGuestOnly() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
err := 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) record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if user != nil { if record != nil {
return err return err
} }
@@ -48,14 +50,57 @@ func RequireGuestOnly() echo.MiddlewareFunc {
} }
} }
// RequireUserAuth middleware requires a request to have // RequireRecordAuth middleware requires a request to have
// a valid user Authorization header set (aka. `Authorization: User ...`). // a valid record auth Authorization header.
func RequireUserAuth() echo.MiddlewareFunc { //
// 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(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
user, _ := c.Get(ContextUserKey).(*models.User) record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if user == nil { if record == nil {
return rest.NewUnauthorizedError("The request requires valid user authorization token to be set.", 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) return next(c)
@@ -64,13 +109,13 @@ func RequireUserAuth() echo.MiddlewareFunc {
} }
// RequireAdminAuth middleware requires a request to have // 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 { func RequireAdminAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin) admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil { 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) return next(c)
@@ -79,14 +124,14 @@ func RequireAdminAuth() echo.MiddlewareFunc {
} }
// RequireAdminAuthOnlyIfAny middleware requires a request to have // RequireAdminAuthOnlyIfAny middleware requires a request to have
// a valid admin Authorization header set (aka. `Authorization: Admin ...`) // a valid admin Authorization header ONLY if the application has
// ONLY if the application has at least 1 existing Admin model. // at least 1 existing Admin model.
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc { func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
totalAdmins, err := app.Dao().TotalAdmins() totalAdmins, err := app.Dao().TotalAdmins()
if err != nil { 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) admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -95,24 +140,29 @@ func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return next(c) 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 // RequireAdminOrRecordAuth middleware requires a request to have
// a valid admin or user Authorization header set // a valid admin or record Authorization header set.
// (aka. `Authorization: Admin ...` or `Authorization: User ...`). //
// You can further filter the allowed auth record collections by providing their names.
// //
// This middleware is the opposite of [apis.RequireGuestOnly()]. // 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(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin) admin, _ := c.Get(ContextAdminKey).(*models.Admin)
user, _ := c.Get(ContextUserKey).(*models.User) record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if admin == nil && user == nil { if admin == nil && record == nil {
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", 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) return next(c)
@@ -121,29 +171,33 @@ func RequireAdminOrUserAuth() echo.MiddlewareFunc {
} }
// RequireAdminOrOwnerAuth middleware requires a request to have // RequireAdminOrOwnerAuth middleware requires a request to have
// a valid admin or user owner Authorization header set // a valid admin or auth record owner Authorization header set.
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
// //
// This middleware is similar to [apis.RequireAdminOrUserAuth()] but // This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
// for the user token expects to have the same id as the path parameter // for the auth record token expects to have the same id as the path
// `ownerIdParam` (default to "id"). // parameter ownerIdParam (default to "id" if empty).
func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc { func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
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 == "" { if ownerIdParam == "" {
ownerIdParam = "id" ownerIdParam = "id"
} }
ownerId := c.PathParam(ownerIdParam) ownerId := c.PathParam(ownerIdParam)
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
if admin == nil && loggedUser == nil { // note: it is "safe" to compare only the record id since the auth
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil) // 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)
if admin == nil && loggedUser.Id != ownerId {
return rest.NewForbiddenError("You are not allowed to perform this request.", nil)
} }
return next(c) return next(c)
@@ -152,32 +206,41 @@ func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
} }
// LoadAuthContext middleware reads the Authorization request header // 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. // 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 { func LoadAuthContext(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
token := c.Request().Header.Get("Authorization") token := c.Request().Header.Get("Authorization")
if token == "" {
return next(c)
}
if token != "" { // the schema is not required and it is only for
if strings.HasPrefix(token, "User ") { // compatibility with the defaults of some HTTP clients
user, err := app.Dao().FindUserByToken( token = strings.TrimPrefix(token, "Bearer ")
token[5:],
app.Settings().UserAuthToken.Secret, claims, _ := security.ParseUnverifiedJWT(token)
) tokenType := cast.ToString(claims["type"])
if err == nil && user != nil {
c.Set(ContextUserKey, user) switch tokenType {
} case tokens.TypeAdmin:
} else if strings.HasPrefix(token, "Admin ") { admin, err := app.Dao().FindAdminByToken(
admin, err := app.Dao().FindAdminByToken( token,
token[6:], app.Settings().AdminAuthToken.Secret,
app.Settings().AdminAuthToken.Secret, )
) if err == nil && admin != nil {
if err == nil && admin != nil { c.Set(ContextAdminKey, admin)
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)
} }
} }
@@ -188,13 +251,19 @@ func LoadAuthContext(app core.App) echo.MiddlewareFunc {
// LoadCollectionContext middleware finds the collection with related // LoadCollectionContext middleware finds the collection with related
// path identifier and loads it into the request context. // 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(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
if param := c.PathParam("collection"); param != "" { if param := c.PathParam("collection"); param != "" {
collection, err := app.Dao().FindCollectionByNameOrId(param) collection, err := app.Dao().FindCollectionByNameOrId(param)
if err != nil || collection == nil { if err != nil || collection == nil {
return rest.NewNotFoundError("", err) return NewNotFoundError("", err)
}
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
return NewBadRequestError("Unsupported collection type.", nil)
} }
c.Set(ContextCollectionKey, collection) c.Set(ContextCollectionKey, collection)
@@ -231,7 +300,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
status = v.Code status = v.Code
meta["errorMessage"] = v.Message meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal) meta["errorDetails"] = fmt.Sprint(v.Internal)
case *rest.ApiError: case *ApiError:
status = v.Code status = v.Code
meta["errorMessage"] = v.Message meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData()) meta["errorDetails"] = fmt.Sprint(v.RawData())
@@ -242,8 +311,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
} }
requestAuth := models.RequestAuthGuest requestAuth := models.RequestAuthGuest
if c.Get(ContextUserKey) != nil { if c.Get(ContextAuthRecordKey) != nil {
requestAuth = models.RequestAuthUser requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil { } else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin requestAuth = models.RequestAuthAdmin
} }
@@ -266,19 +335,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
model.RefreshUpdated() model.RefreshUpdated()
routine.FireAndForget(func() { routine.FireAndForget(func() {
attempts := 1 if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
log.Println("Log save failed:", err)
BeginSave:
logErr := app.LogsDao().SaveRequest(model)
if logErr != nil {
// try one more time after 10s in case of SQLITE_BUSY or "database is locked" error
if attempts <= 2 {
attempts++
time.Sleep(10 * time.Second)
goto BeginSave
} else if app.IsDebug() {
log.Println("Log save failed:", logErr)
}
} }
// Delete old request logs // Delete old request logs
+418 -42
View File
@@ -12,11 +12,11 @@ import (
func TestRequireGuestOnly(t *testing.T) { func TestRequireGuestOnly(t *testing.T) {
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "valid user token", Name: "valid record token",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -38,7 +38,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
@@ -60,7 +60,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -103,7 +103,7 @@ func TestRequireGuestOnly(t *testing.T) {
} }
} }
func TestRequireUserAuth(t *testing.T) { func TestRequireRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -117,7 +117,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(), apis.RequireRecordAuth(),
}, },
}) })
}, },
@@ -129,7 +129,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -139,7 +139,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(), apis.RequireRecordAuth(),
}, },
}) })
}, },
@@ -151,7 +151,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
@@ -161,7 +161,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(), apis.RequireRecordAuth(),
}, },
}) })
}, },
@@ -169,11 +169,11 @@ func TestRequireUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "valid user token", Name: "valid record token",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -183,7 +183,167 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ 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(),
}, },
}) })
}, },
@@ -223,7 +383,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -241,11 +401,11 @@ func TestRequireAdminAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "valid user token", Name: "valid record token",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -267,7 +427,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
@@ -342,7 +502,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -360,11 +520,11 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "valid user token", Name: "valid record token",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -386,7 +546,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
@@ -410,7 +570,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
} }
} }
func TestRequireAdminOrUserAuth(t *testing.T) { func TestRequireAdminOrRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
Name: "guest", Name: "guest",
@@ -424,7 +584,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(), apis.RequireAdminOrRecordAuth(),
}, },
}) })
}, },
@@ -436,7 +596,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -446,7 +606,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(), apis.RequireAdminOrRecordAuth(),
}, },
}) })
}, },
@@ -454,11 +614,11 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "valid user token", Name: "valid record token",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -468,7 +628,51 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ 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,7 +684,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test", Url: "/my/test",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
@@ -490,7 +694,29 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123") return c.String(200, "test123")
}, },
Middlewares: []echo.MiddlewareFunc{ 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,7 +735,7 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{ {
Name: "guest", Name: "guest",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", Url: "/my/test/4q1xlclmfloku33",
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
Method: http.MethodGet, Method: http.MethodGet,
@@ -528,9 +754,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{ {
Name: "expired/invalid token", Name: "expired/invalid token",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -548,12 +774,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "valid user token (different user)", Name: "valid record token (different user)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
// test3@example.com "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs",
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
}, },
BeforeTestFunc: 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{ e.AddRoute(echo.Route{
@@ -571,11 +796,33 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "valid user token (owner)", Name: "valid record token (different collection)",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "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/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) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
@@ -595,9 +842,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{ {
Name: "valid admin token", Name: "valid admin token",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{ e.AddRoute(echo.Route{
@@ -620,3 +867,132 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
scenario.Test(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)
}
}
+175 -99
View File
@@ -15,13 +15,13 @@ import (
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest" "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
) )
// BindRealtimeApi registers the realtime api endpoints. // bindRealtimeApi registers the realtime api endpoints.
func BindRealtimeApi(app core.App, rg *echo.Group) { func bindRealtimeApi(app core.App, rg *echo.Group) {
api := realtimeApi{app: app} api := realtimeApi{app: app}
subGroup := rg.Group("/realtime", ActivityLogger(app)) subGroup := rg.Group("/realtime", ActivityLogger(app))
@@ -43,30 +43,65 @@ func (api *realtimeApi) connect(c echo.Context) error {
// register new subscription client // register new subscription client
client := subscriptions.NewDefaultClient() client := subscriptions.NewDefaultClient()
api.app.SubscriptionsBroker().Register(client) api.app.SubscriptionsBroker().Register(client)
defer api.app.SubscriptionsBroker().Unregister(client.Id()) defer func() {
disconnectEvent := &core.RealtimeDisconnectEvent{
HttpContext: c,
Client: client,
}
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
log.Println(err)
}
api.app.SubscriptionsBroker().Unregister(client.Id())
}()
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8") c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
c.Response().Header().Set("Cache-Control", "no-store") c.Response().Header().Set("Cache-Control", "no-store")
c.Response().Header().Set("Connection", "keep-alive") 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, HttpContext: c,
Client: client, Client: client,
} }
if err := api.app.OnRealtimeConnectRequest().Trigger(event); err != nil { if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
return err return err
} }
if api.app.IsDebug() { if api.app.IsDebug() {
log.Printf("Realtime connection establisehd: %s\n", client.Id()) log.Printf("Realtime connection established: %s\n", client.Id())
} }
// signalize established connection (aka. fire "connect" message) // signalize established connection (aka. fire "connect" message)
fmt.Fprint(c.Response(), "id:"+client.Id()+"\n") connectMsgEvent := &core.RealtimeMessageEvent{
fmt.Fprint(c.Response(), "event:PB_CONNECT\n") HttpContext: c,
fmt.Fprint(c.Response(), "data:{\"clientId\":\""+client.Id()+"\"}\n\n") Client: client,
c.Response().Flush() 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 // start an idle timer to keep track of inactive/forgotten connections
idleDuration := 5 * time.Minute idleDuration := 5 * time.Minute
@@ -86,11 +121,29 @@ func (api *realtimeApi) connect(c echo.Context) error {
return nil return nil
} }
w := c.Response() msgEvent := &core.RealtimeMessageEvent{
fmt.Fprint(w, "id:"+client.Id()+"\n") HttpContext: c,
fmt.Fprint(w, "event:"+msg.Name+"\n") Client: client,
fmt.Fprint(w, "data:"+msg.Data+"\n\n") Message: &msg,
w.Flush() }
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.Stop()
idleTimer.Reset(idleDuration) idleTimer.Reset(idleDuration)
@@ -110,25 +163,25 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// read request data // read request data
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("", err) return NewBadRequestError("", err)
} }
// validate request data // validate request data
if err := form.Validate(); err != nil { if err := form.Validate(); err != nil {
return rest.NewBadRequestError("", err) return NewBadRequestError("", err)
} }
// find subscription client // find subscription client
client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId) client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
if err != nil { 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 // check if the previous request was authorized
oldAuthId := extractAuthIdFromGetter(client) oldAuthId := extractAuthIdFromGetter(client)
newAuthId := extractAuthIdFromGetter(c) newAuthId := extractAuthIdFromGetter(c)
if oldAuthId != "" && oldAuthId != newAuthId { 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{ event := &core.RealtimeSubscribeEvent{
@@ -140,7 +193,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error { handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
// update auth state // update auth state
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey)) 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 // unsubscribe from any previous existing subscriptions
e.Client.Unsubscribe() e.Client.Unsubscribe()
@@ -158,75 +211,80 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
return handlerErr 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() { func (api *realtimeApi) bindEvents() {
userTable := (&models.User{}).TableName() // update the clients that has admin or auth record association
adminTable := (&models.Admin{}).TableName() api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
// update user/admin auth state return api.updateClientsAuthModel(ContextAuthRecordKey, record)
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
modelTable := e.Model.TableName()
var contextKey string
switch modelTable {
case userTable:
contextKey = ContextUserKey
case adminTable:
contextKey = ContextAdminKey
default:
return nil
} }
for _, client := range api.app.SubscriptionsBroker().Clients() { if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
model, _ := client.Get(contextKey).(models.Model) return api.updateClientsAuthModel(ContextAdminKey, admin)
if model != nil && model.GetId() == e.Model.GetId() { }
client.Set(contextKey, e.Model)
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)
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
}
return nil
})
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
log.Println(err)
} }
} }
return nil return nil
}) })
// remove user/admin client(s) api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error { if record, ok := e.Model.(*models.Record); ok {
modelTable := e.Model.TableName() if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
log.Println(err)
var contextKey string
switch modelTable {
case userTable:
contextKey = ContextUserKey
case adminTable:
contextKey = ContextAdminKey
default:
return nil
}
for _, client := range api.app.SubscriptionsBroker().Clients() {
model, _ := client.Get(contextKey).(models.Model)
if model != nil && model.GetId() == e.Model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
} }
} }
return nil
})
api.app.OnModelAfterCreate().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("create", record)
}
return nil
})
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("update", record)
}
return nil return nil
}) })
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error { api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok { if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("delete", record) if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return nil return nil
}) })
@@ -250,18 +308,12 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
} }
// emulate request data // emulate request data
requestData := map[string]any{ requestData := &models.RequestData{
"method": "get", 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.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) expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil { if err != nil {
return err return err
@@ -272,7 +324,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
return nil 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 { if err == nil && foundRecord != nil {
return true return true
} }
@@ -296,24 +348,26 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return nil // no subscribers return nil // no subscribers
} }
// remove the expand from the broadcasted record because we don't // create a clean record copy without expand and unknown fields
// know if the clients have access to view the expanded records // because we don't know if the clients have permissions to view them
cleanRecord := *record cleanRecord := record.CleanCopy()
cleanRecord.SetExpand(nil)
subscriptionRuleMap := map[string]*string{ subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule, (collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule, (collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
collection.Name: collection.ListRule, (collection.Name + "/*"): collection.ListRule,
collection.Id: collection.ListRule, (collection.Id + "/*"): collection.ListRule,
// @deprecated: the same as the wildcard topic but kept for backward compatibility
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
} }
data := &recordData{ data := &recordData{
Action: action, Action: action,
Record: &cleanRecord, Record: cleanRecord,
} }
serializedData, err := json.Marshal(data) dataBytes, err := json.Marshal(data)
if err != nil { if err != nil {
if api.app.IsDebug() { if api.app.IsDebug() {
log.Println(err) log.Println(err)
@@ -321,7 +375,11 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return err return err
} }
encodedData := string(dataBytes)
for _, client := range clients { for _, client := range clients {
client := client
for subscription, rule := range subscriptionRuleMap { for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) { if !client.HasSubscription(subscription) {
continue continue
@@ -333,10 +391,28 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
msg := subscriptions.Message{ msg := subscriptions.Message{
Name: subscription, Name: subscription,
Data: string(serializedData), Data: encodedData,
} }
client.Channel() <- msg // 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
}
}
routine.FireAndForget(func() {
if !client.IsDiscarded() {
client.Channel() <- msg
}
})
} }
} }
@@ -348,9 +424,9 @@ type getter interface {
} }
func extractAuthIdFromGetter(val getter) string { func extractAuthIdFromGetter(val getter) string {
user, _ := val.Get(ContextUserKey).(*models.User) record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
if user != nil { if record != nil {
return user.Id return record.Id
} }
admin, _ := val.Get(ContextAdminKey).(*models.Admin) admin, _ := val.Get(ContextAdminKey).(*models.Admin)
+81 -30
View File
@@ -1,6 +1,7 @@
package apis_test package apis_test
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -10,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
) )
@@ -25,7 +27,56 @@ func TestRealtimeConnect(t *testing.T) {
`data:{"clientId":`, `data:{"clientId":`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1, "OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
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) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(app.SubscriptionsBroker().Clients()) != 0 { if len(app.SubscriptionsBroker().Clients()) != 0 {
@@ -46,7 +97,7 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() { resetClient := func() {
client.Unsubscribe() client.Unsubscribe()
client.Set(apis.ContextAdminKey, nil) client.Set(apis.ContextAdminKey, nil)
client.Set(apis.ContextUserKey, nil) client.Set(apis.ContextAuthRecordKey, nil)
} }
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
@@ -113,7 +164,7 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime", Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 204, ExpectedStatus: 204,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -132,12 +183,12 @@ func TestRealtimeSubscribe(t *testing.T) {
}, },
}, },
{ {
Name: "existing client - authorized user", Name: "existing client - authorized record",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/realtime", Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 204, ExpectedStatus: 204,
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
@@ -148,9 +199,9 @@ func TestRealtimeSubscribe(t *testing.T) {
app.SubscriptionsBroker().Register(client) app.SubscriptionsBroker().Register(client)
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, _ := client.Get(apis.ContextUserKey).(*models.User) authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if user == nil { if authRecord == nil {
t.Errorf("Expected user auth model, got nil") t.Errorf("Expected auth record model, got nil")
} }
resetClient() resetClient()
}, },
@@ -161,21 +212,21 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime", Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 403, ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
initialAuth := &models.User{} initialAuth := &models.Record{}
initialAuth.RefreshId() initialAuth.RefreshId()
client.Set(apis.ContextUserKey, initialAuth) client.Set(apis.ContextAuthRecordKey, initialAuth)
app.SubscriptionsBroker().Register(client) app.SubscriptionsBroker().Register(client)
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, _ := client.Get(apis.ContextUserKey).(*models.User) authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if user == nil { if authRecord == nil {
t.Errorf("Expected user auth model, got nil") t.Errorf("Expected auth record model, got nil")
} }
resetClient() resetClient()
}, },
@@ -187,55 +238,55 @@ func TestRealtimeSubscribe(t *testing.T) {
} }
} }
func TestRealtimeUserDeleteEvent(t *testing.T) { func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
apis.InitApi(testApp) apis.InitApi(testApp)
user, err := testApp.Dao().FindUserByEmail("test@example.com") authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
client := subscriptions.NewDefaultClient() client := subscriptions.NewDefaultClient()
client.Set(apis.ContextUserKey, user) client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client) 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 { if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) 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() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
apis.InitApi(testApp) apis.InitApi(testApp)
user1, err := testApp.Dao().FindUserByEmail("test@example.com") authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
client := subscriptions.NewDefaultClient() client := subscriptions.NewDefaultClient()
client.Set(apis.ContextUserKey, user1) client.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client) testApp.SubscriptionsBroker().Register(client)
// refetch the user and change its email // refetch the authRecord and change its email
user2, err := testApp.Dao().FindUserByEmail("test@example.com") authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) 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) clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientUser.Email != user2.Email { if clientAuthRecord.Email() != authRecord2.Email() {
t.Fatalf("Expected user with email %q, got %q", user2.Email, clientUser.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) client.Set(apis.ContextAdminKey, admin1)
testApp.SubscriptionsBroker().Register(client) 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") admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -287,6 +338,6 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin) clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email { 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)
} }
} }
-454
View File
@@ -1,454 +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 users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil {
return err
}
requestData := api.exportRequestData(c)
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection)).
CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
if admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryString(), &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 {
failed := api.app.Dao().ExpandRecords(
records,
expands,
api.expandFunc(c, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
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)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
record,
strings.Split(c.QueryParam(expandQueryParam), ","),
api.expandFunc(c, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
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 load 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 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 rest.NewBadRequestError("Failed to create record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
api.expandFunc(e.HttpContext, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
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 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 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 rest.NewBadRequestError("Failed to update record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
api.expandFunc(e.HttpContext, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
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 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)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
if handlerErr == nil {
api.app.OnRecordAfterDeleteRequest().Trigger(event)
}
return handlerErr
}
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) 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 rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
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
})
}
}
+617
View File
@@ -0,0 +1,617 @@
package apis
import (
"errors"
"fmt"
"log"
"net/http"
"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/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)
subGroup.POST("/auth-with-password", api.authWithPassword)
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) authRefresh(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewNotFoundError("Missing auth record context.", nil)
}
event := new(core.RecordAuthRefreshEvent)
event.HttpContext = c
event.Collection = record.Collection()
event.Record = record
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
if handlerErr == nil {
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
type providerInfo struct {
Name string `json:"name"`
State string `json:"state"`
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)
}
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := RequestData(c)
requestData.Data = form.CreateData
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
})
})
event := new(core.RecordAuthWithOAuth2Event)
event.HttpContext = c
event.Collection = collection
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
return func(data *forms.RecordOAuth2LoginData) error {
event.Record = data.Record
event.OAuth2User = data.OAuth2User
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
data.Record = e.Record
data.OAuth2User = e.OAuth2User
if err := next(data); err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
e.Record = data.Record
e.OAuth2User = data.OAuth2User
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
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)
}
event := new(core.RecordAuthWithPasswordEvent)
event.HttpContext = c
event.Collection = collection
event.Password = form.Password
event.Identity = form.Identity
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to authenticate.", err)
}
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
})
}
})
if submitErr == nil {
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
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 := new(core.RecordRequestPasswordResetEvent)
event.HttpContext = c
event.Collection = collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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 {
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
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 := new(core.RecordConfirmPasswordResetEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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 {
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
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 := new(core.RecordRequestVerificationEvent)
event.HttpContext = c
event.Collection = collection
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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 {
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
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 := new(core.RecordConfirmVerificationEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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 {
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
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 := new(core.RecordRequestEmailChangeEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to request email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
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 := new(core.RecordConfirmEmailChangeEvent)
event.HttpContext = c
event.Collection = collection
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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 {
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
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 := new(core.RecordListExternalAuthsEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.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 := new(core.RecordUnlinkExternalAuthEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.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
+414
View File
@@ -0,0 +1,414 @@
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),
)
subGroup.GET("/records", api.list, LoadCollectionContext(app))
subGroup.GET("/records/:id", api.view, LoadCollectionContext(app))
subGroup.POST("/records", api.create, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.PATCH("/records/:id", api.update, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
subGroup.DELETE("/records/:id", api.delete, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
}
type recordApi struct {
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))
}
records := []*models.Record{}
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
if err != nil {
return NewBadRequestError("Invalid filter parameters.", err)
}
event := new(core.RecordsListEvent)
event.HttpContext = c
event.Collection = collection
event.Records = records
event.Result = result
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
log.Println(err)
}
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 := new(core.RecordViewEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
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 {
testRecord := models.NewRecord(collection)
// replace modifiers fields so that the resolved value is always
// available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
}
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)
}
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
}
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 := new(core.RecordCreateEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.UploadedFiles = form.FilesToUpload()
// create the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if err := next(e.Record); 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 {
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordApi) update(c echo.Context) error {
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)
}
// eager fetch the record so that the modifier field values are replaced
// and available when accessing requestData.Data using just the field name
if requestData.HasModifierDataKeys() {
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
requestData.Data = record.ReplaceModifers(requestData.Data)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
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 := new(core.RecordUpdateEvent)
event.HttpContext = c
event.Collection = collection
event.Record = record
event.UploadedFiles = form.FilesToUpload()
// update the record
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(m *models.Record) error {
event.Record = m
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if err := next(e.Record); 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 {
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return submitErr
}
func (api *recordApi) delete(c echo.Context) error {
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 := new(core.RecordDeleteEvent)
event.HttpContext = c
event.Collection = collection
event.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 {
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
}
return handlerErr
}
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // admins are allowed to query everything
}
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
forbiddenFields := []string{"@collection.", "@request."}
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
File diff suppressed because it is too large. Load diff
+269
View File
@@ -0,0 +1,269 @@
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/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
const ContextRequestDataKey = "requestData"
// 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
}
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := new(core.RecordAuthEvent)
event.HttpContext = c
event.Collection = authRecord.Collection()
event.Record = authRecord
event.Token = token
event.Meta = meta
return 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 := app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(app.Dao(), &requestData),
)
if len(failed) > 0 && 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)
})
}
// 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
}
+213
View File
@@ -0,0 +1,213 @@
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 TestRecordAuthResponse(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
dummyAdmin := &models.Admin{}
dummyAdmin.Id = "id1"
nonAuthRecord, err := app.Dao().FindRecordById("demo1", "al1h9ijdeojtsjy")
if err != nil {
t.Fatal(err)
}
authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
record *models.Record
meta any
expectError bool
expectedContent []string
notExpectedContent []string
expectedEvents map[string]int
}{
{
name: "non auth record",
record: nonAuthRecord,
expectError: true,
},
{
name: "valid auth record - without meta",
record: authRecord,
expectError: false,
expectedContent: []string{
`"token":"`,
`"record":{`,
`"id":"`,
`"expand":{"rel":{`,
},
notExpectedContent: []string{
`"meta":`,
},
expectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
},
},
{
name: "valid auth record - with meta",
record: authRecord,
meta: map[string]any{"meta_test": 123},
expectError: false,
expectedContent: []string{
`"token":"`,
`"record":{`,
`"id":"`,
`"expand":{"rel":{`,
`"meta":{"meta_test":123`,
},
expectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
},
},
}
for _, s := range scenarios {
app.ResetEventCalls()
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set(apis.ContextAdminKey, dummyAdmin)
responseErr := apis.RecordAuthResponse(app, c, s.record, s.meta)
hasErr := responseErr != nil
if hasErr != s.expectError {
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, responseErr)
}
if len(app.EventCalls) != len(s.expectedEvents) {
t.Fatalf("[%s] Expected events \n%v, \ngot \n%v", s.name, s.expectedEvents, app.EventCalls)
}
for k, v := range s.expectedEvents {
if app.EventCalls[k] != v {
t.Fatalf("[%s] Expected event %s to be called %d times, got %d", s.name, k, v, app.EventCalls[k])
}
}
if hasErr {
continue
}
response := rec.Body.String()
for _, v := range s.expectedContent {
if !strings.Contains(response, v) {
t.Fatalf("[%s] Missing %v in response \n%v", s.name, v, response)
}
}
for _, v := range s.notExpectedContent {
if strings.Contains(response, v) {
t.Fatalf("[%s] Unexpected %v in response \n%v", s.name, v, response)
}
}
}
}
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)
}
}
}
}
-1052
View File
File diff suppressed because it is too large. Load diff
+35 -29
View File
@@ -1,18 +1,20 @@
package apis package apis
import ( import (
"fmt"
"log"
"net/http" "net/http"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tools/rest" "github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
) )
// BindSettingsApi registers the settings api endpoints. // bindSettingsApi registers the settings api endpoints.
func BindSettingsApi(app core.App, rg *echo.Group) { func bindSettingsApi(app core.App, rg *echo.Group) {
api := settingsApi{app: app} api := settingsApi{app: app}
subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth()) subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth())
@@ -29,13 +31,12 @@ type settingsApi struct {
func (api *settingsApi) list(c echo.Context) error { func (api *settingsApi) list(c echo.Context) error {
settings, err := api.app.Settings().RedactClone() settings, err := api.app.Settings().RedactClone()
if err != nil { if err != nil {
return rest.NewBadRequestError("", err) return NewBadRequestError("", err)
} }
event := &core.SettingsListEvent{ event := new(core.SettingsListEvent)
HttpContext: c, event.HttpContext = c
RedactedSettings: settings, event.RedactedSettings = settings
}
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error { return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings) return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
@@ -47,26 +48,26 @@ func (api *settingsApi) set(c echo.Context) error {
// load request // load request
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err) return NewBadRequestError("An error occurred while loading the submitted data.", err)
} }
event := &core.SettingsUpdateEvent{ event := new(core.SettingsUpdateEvent)
HttpContext: c, event.HttpContext = c
OldSettings: api.app.Settings(), event.OldSettings = api.app.Settings()
NewSettings: form.Settings,
}
// update the settings // update the settings
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
return func() error { return func(s *settings.Settings) error {
event.NewSettings = s
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error { return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil { if err := next(e.NewSettings); err != nil {
return rest.NewBadRequestError("An error occurred while submitting the form.", err) return NewBadRequestError("An error occurred while submitting the form.", err)
} }
redactedSettings, err := api.app.Settings().RedactClone() redactedSettings, err := api.app.Settings().RedactClone()
if err != nil { if err != nil {
return rest.NewBadRequestError("", err) return NewBadRequestError("", err)
} }
return e.HttpContext.JSON(http.StatusOK, redactedSettings) return e.HttpContext.JSON(http.StatusOK, redactedSettings)
@@ -75,7 +76,9 @@ func (api *settingsApi) set(c echo.Context) error {
}) })
if submitErr == nil { if submitErr == nil {
api.app.OnSettingsAfterUpdateRequest().Trigger(event) if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
log.Println(err)
}
} }
return submitErr return submitErr
@@ -83,23 +86,26 @@ func (api *settingsApi) set(c echo.Context) error {
func (api *settingsApi) testS3(c echo.Context) error { func (api *settingsApi) testS3(c echo.Context) error {
if !api.app.Settings().S3.Enabled { if !api.app.Settings().S3.Enabled {
return rest.NewBadRequestError("S3 storage is not enabled.", nil) return NewBadRequestError("S3 storage is not enabled.", nil)
} }
fs, err := api.app.NewFilesystem() fs, err := api.app.NewFilesystem()
if err != nil { if err != nil {
return rest.NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil) return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
} }
defer fs.Close() defer fs.Close()
testFileKey := "pb_test_" + security.RandomString(5) + "/test.txt" testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
testFileKey := testPrefix + "/test.txt"
// try to upload a test file
if err := fs.Upload([]byte("test"), testFileKey); err != nil { if err := fs.Upload([]byte("test"), testFileKey); err != nil {
return rest.NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil) return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
} }
if err := fs.Delete(testFileKey); err != nil { // test prefix deletion (ensures that both bucket list and delete works)
return rest.NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil) if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
} }
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
@@ -110,18 +116,18 @@ func (api *settingsApi) testEmail(c echo.Context) error {
// load request // load request
if err := c.Bind(form); err != nil { if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err) return NewBadRequestError("An error occurred while loading the submitted data.", err)
} }
// send // send
if err := form.Submit(); err != nil { if err := form.Submit(); err != nil {
if fErr, ok := err.(validation.Errors); ok { if fErr, ok := err.(validation.Errors); ok {
// form error // form error
return rest.NewBadRequestError("Failed to send the test email.", fErr) return NewBadRequestError("Failed to send the test email.", fErr)
} }
// mailer error // mailer error
return rest.NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil) return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
} }
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
+99 -56
View File
@@ -19,11 +19,11 @@ func TestSettingsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as auth record",
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/settings", Url: "/api/settings",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -33,7 +33,7 @@ func TestSettingsList(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
Url: "/api/settings", Url: "/api/settings",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
@@ -43,16 +43,28 @@ func TestSettingsList(t *testing.T) {
`"s3":{`, `"s3":{`,
`"adminAuthToken":{`, `"adminAuthToken":{`,
`"adminPasswordResetToken":{`, `"adminPasswordResetToken":{`,
`"userAuthToken":{`, `"recordAuthToken":{`,
`"userPasswordResetToken":{`, `"recordPasswordResetToken":{`,
`"userEmailChangeToken":{`, `"recordEmailChangeToken":{`,
`"userVerificationToken":{`, `"recordVerificationToken":{`,
`"emailAuth":{`, `"emailAuth":{`,
`"googleAuth":{`, `"googleAuth":{`,
`"facebookAuth":{`, `"facebookAuth":{`,
`"githubAuth":{`, `"githubAuth":{`,
`"gitlabAuth":{`, `"gitlabAuth":{`,
`"twitterAuth":{`,
`"discordAuth":{`, `"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
}, },
@@ -68,7 +80,7 @@ func TestSettingsList(t *testing.T) {
} }
func TestSettingsSet(t *testing.T) { func TestSettingsSet(t *testing.T) {
validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}` validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
@@ -80,12 +92,12 @@ func TestSettingsSet(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as auth record",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/settings", Url: "/api/settings",
Body: strings.NewReader(validData), Body: strings.NewReader(validData),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -96,7 +108,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings", Url: "/api/settings",
Body: strings.NewReader(``), Body: strings.NewReader(``),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
@@ -106,20 +118,30 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`, `"s3":{`,
`"adminAuthToken":{`, `"adminAuthToken":{`,
`"adminPasswordResetToken":{`, `"adminPasswordResetToken":{`,
`"userAuthToken":{`, `"recordAuthToken":{`,
`"userPasswordResetToken":{`, `"recordPasswordResetToken":{`,
`"userEmailChangeToken":{`, `"recordEmailChangeToken":{`,
`"userVerificationToken":{`, `"recordVerificationToken":{`,
`"emailAuth":{`, `"emailAuth":{`,
`"googleAuth":{`, `"googleAuth":{`,
`"facebookAuth":{`, `"facebookAuth":{`,
`"githubAuth":{`, `"githubAuth":{`,
`"gitlabAuth":{`, `"gitlabAuth":{`,
`"discordAuth":{`, `"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
`"appName":"Acme"`, `"appName":"acme_test"`,
`"minPasswordLength":8`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
@@ -132,15 +154,14 @@ func TestSettingsSet(t *testing.T) {
Name: "authorized as admin submitting invalid data", Name: "authorized as admin submitting invalid data",
Method: http.MethodPatch, Method: http.MethodPatch,
Url: "/api/settings", Url: "/api/settings",
Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`), Body: strings.NewReader(`{"meta":{"appName":""}}`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{ ExpectedContent: []string{
`"data":{`, `"data":{`,
`"emailAuth":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required","message":"Must be no less than 5."}}`, `"meta":{"appName":{"code":"validation_required"`,
`"meta":{"appName":{"code":"validation_required","message":"Cannot be blank."}}`,
}, },
}, },
{ {
@@ -149,7 +170,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings", Url: "/api/settings",
Body: strings.NewReader(validData), Body: strings.NewReader(validData),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
@@ -159,20 +180,31 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`, `"s3":{`,
`"adminAuthToken":{`, `"adminAuthToken":{`,
`"adminPasswordResetToken":{`, `"adminPasswordResetToken":{`,
`"userAuthToken":{`, `"recordAuthToken":{`,
`"userPasswordResetToken":{`, `"recordPasswordResetToken":{`,
`"userEmailChangeToken":{`, `"recordEmailChangeToken":{`,
`"userVerificationToken":{`, `"recordVerificationToken":{`,
`"emailAuth":{`, `"emailAuth":{`,
`"googleAuth":{`, `"googleAuth":{`,
`"facebookAuth":{`, `"facebookAuth":{`,
`"githubAuth":{`, `"githubAuth":{`,
`"gitlabAuth":{`, `"gitlabAuth":{`,
`"twitterAuth":{`,
`"discordAuth":{`, `"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"stravaAuth":{`,
`"giteeAuth":{`,
`"livechatAuth":{`,
`"giteaAuth":{`,
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"secret":"******"`, `"secret":"******"`,
`"clientSecret":"******"`, `"clientSecret":"******"`,
`"appName":"update_test"`, `"appName":"update_test"`,
`"minPasswordLength":12`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
@@ -198,11 +230,11 @@ func TestSettingsTestS3(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as auth record",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/settings/test/s3", Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -212,12 +244,11 @@ func TestSettingsTestS3(t *testing.T) {
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/settings/test/s3", Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
// @todo consider creating a test S3 filesystem
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
@@ -239,7 +270,7 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
}, },
{ {
Name: "authorized as user", Name: "authorized as auth record",
Method: http.MethodPost, Method: http.MethodPost,
Url: "/api/settings/test/email", Url: "/api/settings/test/email",
Body: strings.NewReader(`{ Body: strings.NewReader(`{
@@ -247,7 +278,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com" "email": "test@example.com"
}`), }`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
}, },
ExpectedStatus: 401, ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -258,7 +289,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email", Url: "/api/settings/test/email",
Body: strings.NewReader(`{`), Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`}, ExpectedContent: []string{`"data":{}`},
@@ -269,7 +300,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email", Url: "/api/settings/test/email",
Body: strings.NewReader(`{}`), Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
ExpectedStatus: 400, ExpectedStatus: 400,
ExpectedContent: []string{ ExpectedContent: []string{
@@ -286,26 +317,30 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com" "email": "test@example.com"
}`), }`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 { if app.TestMailer.TotalSend != 1 {
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend) t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
} }
if app.TestMailer.LastToAddress.Address != "test@example.com" { if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address) t.Fatalf("[verification] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
} }
if !strings.Contains(app.TestMailer.LastHtmlBody, "Verify") { if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody) t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
} }
}, },
ExpectedStatus: 204, ExpectedStatus: 204,
ExpectedContent: []string{}, ExpectedContent: []string{},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnMailerBeforeUserVerificationSend": 1, "OnMailerBeforeRecordVerificationSend": 1,
"OnMailerAfterUserVerificationSend": 1, "OnMailerAfterRecordVerificationSend": 1,
}, },
}, },
{ {
@@ -317,26 +352,30 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com" "email": "test@example.com"
}`), }`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 { if app.TestMailer.TotalSend != 1 {
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend) t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
} }
if app.TestMailer.LastToAddress.Address != "test@example.com" { if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address) t.Fatalf("[password-reset] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
} }
if !strings.Contains(app.TestMailer.LastHtmlBody, "Reset password") { if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody) t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
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, ExpectedStatus: 204,
ExpectedContent: []string{}, ExpectedContent: []string{},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnMailerBeforeUserResetPasswordSend": 1, "OnMailerBeforeRecordResetPasswordSend": 1,
"OnMailerAfterUserResetPasswordSend": 1, "OnMailerAfterRecordResetPasswordSend": 1,
}, },
}, },
{ {
@@ -348,26 +387,30 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com" "email": "test@example.com"
}`), }`),
RequestHeaders: map[string]string{ RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
}, },
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 { if app.TestMailer.TotalSend != 1 {
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend) t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
} }
if app.TestMailer.LastToAddress.Address != "test@example.com" { if len(app.TestMailer.LastMessage.To) != 1 {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address) t.Fatalf("[email-change] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
} }
if !strings.Contains(app.TestMailer.LastHtmlBody, "Confirm new email") { if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody) t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
}
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
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, ExpectedStatus: 204,
ExpectedContent: []string{}, ExpectedContent: []string{},
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"OnMailerBeforeUserChangeEmailSend": 1, "OnMailerBeforeRecordChangeEmailSend": 1,
"OnMailerAfterUserChangeEmailSend": 1, "OnMailerAfterRecordChangeEmailSend": 1,
}, },
}, },
} }
-519
View File
@@ -1,519 +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"))
subGroup.GET("/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
subGroup.DELETE("/:id/external-auths/:provider", api.unlinkExternalAuth, 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(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 *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 loading 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 loading 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 loading 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 loading 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 loading 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 loading 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 loading 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 loading 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 load the submitted data due to invalid formatting.", err)
}
event := &core.UserCreateEvent{
HttpContext: c,
User: user,
}
// create the user
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to create user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
})
if submitErr == nil {
api.app.OnUserAfterCreateRequest().Trigger(event)
}
return submitErr
}
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 load the submitted data due to invalid formatting.", err)
}
event := &core.UserUpdateEvent{
HttpContext: c,
User: user,
}
// update the user
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to update user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
})
if submitErr == nil {
api.app.OnUserAfterUpdateRequest().Trigger(event)
}
return submitErr
}
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
}
func (api *userApi) listExternalAuths(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)
}
externalAuths, err := api.app.Dao().FindAllExternalAuthsByUserId(user.Id)
if err != nil {
return rest.NewBadRequestError("Failed to fetch the external auths for the specified user.", err)
}
event := &core.UserListExternalAuthsEvent{
HttpContext: c,
User: user,
ExternalAuths: externalAuths,
}
return api.app.OnUserListExternalAuths().Trigger(event, func(e *core.UserListExternalAuthsEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
})
}
func (api *userApi) unlinkExternalAuth(c echo.Context) error {
id := c.PathParam("id")
provider := c.PathParam("provider")
if id == "" || provider == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
externalAuth, err := api.app.Dao().FindExternalAuthByUserIdAndProvider(user.Id, provider)
if err != nil {
return rest.NewNotFoundError("Missing external auth provider relation.", err)
}
event := &core.UserUnlinkExternalAuthEvent{
HttpContext: c,
User: user,
ExternalAuth: externalAuth,
}
handlerErr := api.app.OnUserBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.UserUnlinkExternalAuthEvent) error {
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
return rest.NewBadRequestError("Cannot unlink the external auth provider. Make sure that the user has other linked auth providers OR has an email address.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
if handlerErr == nil {
api.app.OnUserAfterUnlinkExternalAuthRequest().Trigger(event)
}
return handlerErr
}
-1113
View File
File diff suppressed because it is too large. Load diff
-241
View File
@@ -1,241 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"log"
"os"
"path"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/inflector"
"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 name [folder] - creates new migration template file.
- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
`
var databaseFlag string
command := &cobra.Command{
Use: "migrate",
Short: "Executes DB migration scripts",
ValidArgs: []string{"up", "down", "create", "collections"},
Long: desc,
Run: func(command *cobra.Command, args []string) {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
// additional commands
// ---
if cmd == "create" {
if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil {
log.Fatal(err)
}
return
}
if cmd == "collections" {
if err := migrateCollectionsHandler(app, args[1:]); err != nil {
log.Fatal(err)
}
return
}
// ---
// 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,
},
}
}
// -------------------------------------------------------------------
// migrate create
// -------------------------------------------------------------------
const defaultMigrateCreateTemplate = `package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
func migrateCreateHandler(template string, args []string) error {
if len(args) < 1 {
return fmt.Errorf("Missing migration file name")
}
name := args[0]
var dir string
if len(args) == 2 {
dir = args[1]
}
if dir == "" {
// If not specified, auto point to the default migrations folder.
//
// NB!
// Since the create command makes sense only during development,
// it is expected the user to be in the app working directory
// and to be using `go run`
wd, err := os.Getwd()
if err != nil {
return err
}
dir = path.Join(wd, "migrations")
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
)
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
// ensure that migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
}
// -------------------------------------------------------------------
// migrate collections
// -------------------------------------------------------------------
const collectionsMigrateCreateTemplate = `package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
// Auto generated migration with the most recent collections configuration.
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := ` + "`" + `%s` + "`" + `
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
// no revert since the configuration on the environment, on which
// the migration was executed, could have changed via the UI/API
return nil
})
}
`
func migrateCollectionsHandler(app core.App, args []string) error {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
dao := daos.New(app.DB())
collections := []*models.Collection{}
if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("Failed to fetch migrations list: %v", err)
}
serialized, err := json.MarshalIndent(collections, "\t\t", "\t")
if err != nil {
return fmt.Errorf("Failed to serialize collections list: %v", err)
}
return migrateCreateHandler(
fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)),
createArgs,
)
}
+35 -7
View File
@@ -6,12 +6,16 @@ import (
"net" "net"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/labstack/echo/v5/middleware" "github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/migrate" "github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/crypto/acme" "golang.org/x/crypto/acme"
@@ -38,7 +42,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
// (or if this is the first time the init migration was executed) // (or if this is the first time the init migration was executed)
if err := app.RefreshSettings(); err != nil { if err := app.RefreshSettings(); err != nil {
color.Yellow("=====================================") 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("Fallback to the application defaults.")
color.Yellow("=====================================") color.Yellow("=====================================")
} }
@@ -75,7 +79,8 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
GetCertificate: certManager.GetCertificate, GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto}, NextProtos: []string{acme.ALPNProto},
}, },
ReadTimeout: 60 * time.Second, ReadTimeout: 5 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse! // WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router, Handler: router,
Addr: mainAddr, Addr: mainAddr,
@@ -86,11 +91,20 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
if httpsAddr != "" { if httpsAddr != "" {
schema = "https" schema = "https"
} }
regular := color.New()
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
bold := color.New(color.Bold).Add(color.FgGreen) bold := color.New(color.Bold).Add(color.FgGreen)
bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr)) bold.Printf(
regular.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr)) "%s Server started at %s\n",
regular.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr)) strings.TrimSpace(date.String()),
color.CyanString("%s://%s", schema, serverConfig.Addr),
)
regular := color.New()
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
} }
var serveErr error var serveErr error
@@ -137,8 +151,22 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
return command return command
} }
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func runMigrations(app core.App) error { 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 { for _, c := range connections {
runner, err := migrate.NewRunner(c.DB, c.MigrationsList) 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
}
+400 -79
View File
@@ -6,6 +6,7 @@ package core
import ( import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
@@ -15,6 +16,11 @@ import (
// App defines the main PocketBase app interface. // App defines the main PocketBase app interface.
type App interface { type App interface {
// Deprecated:
// This method may get removed in the near future.
// It is recommended to access the app db instance from app.Dao().DB() or
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
//
// DB returns the default app database instance. // DB returns the default app database instance.
DB() *dbx.DB DB() *dbx.DB
@@ -25,6 +31,11 @@ type App interface {
// trying to access the request logs table will result in error. // trying to access the request logs table will result in error.
Dao() *daos.Dao Dao() *daos.Dao
// Deprecated:
// This method may get removed in the near future.
// It is recommended to access the logs db instance from app.LogsDao().DB() or
// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
//
// LogsDB returns the app logs database instance. // LogsDB returns the app logs database instance.
LogsDB() *dbx.DB LogsDB() *dbx.DB
@@ -47,7 +58,7 @@ type App interface {
IsDebug() bool IsDebug() bool
// Settings returns the loaded app settings. // Settings returns the loaded app settings.
Settings() *Settings Settings() *settings.Settings
// Cache returns the app internal cache store. // Cache returns the app internal cache store.
Cache() *store.Store[any] Cache() *store.Store[any]
@@ -67,8 +78,14 @@ type App interface {
// RefreshSettings reinitializes and reloads the stored application settings. // RefreshSettings reinitializes and reloads the stored application settings.
RefreshSettings() error RefreshSettings() error
// IsBootstrapped checks if the application was initialized
// (aka. whether Bootstrap() was called).
IsBootstrapped() bool
// Bootstrap takes care for initializing the application // Bootstrap takes care for initializing the application
// (open db connections, load settings, etc.) // (open db connections, load settings, etc.).
//
// It will call ResetBootstrapState() if the application was already bootstrapped.
Bootstrap() error Bootstrap() error
// ResetBootstrapState takes care for releasing initialized app resources // ResetBootstrapState takes care for releasing initialized app resources
@@ -79,37 +96,79 @@ type App interface {
// App event hooks // 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), // OnBeforeServe hook is triggered before serving the internal router (echo),
// allowing you to adjust its options and attach new routes. // allowing you to adjust its options and attach new routes.
OnBeforeServe() *hook.Hook[*ServeEvent] 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 // Dao event hooks
// --------------------------------------------------------------- // ---------------------------------------------------------------
// OnModelBeforeCreate hook is triggered before inserting a new // OnModelBeforeCreate hook is triggered before inserting a new
// entry in the DB, allowing you to modify or validate the stored data. // entry in the DB, allowing you to modify or validate the stored data.
OnModelBeforeCreate() *hook.Hook[*ModelEvent] //
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterCreate hook is triggered after successfully // OnModelAfterCreate hook is triggered after successfully
// inserting a new entry in the DB. // inserting a new entry in the DB.
OnModelAfterCreate() *hook.Hook[*ModelEvent] //
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeUpdate hook is triggered before updating existing // OnModelBeforeUpdate hook is triggered before updating existing
// entry in the DB, allowing you to modify or validate the stored data. // entry in the DB, allowing you to modify or validate the stored data.
OnModelBeforeUpdate() *hook.Hook[*ModelEvent] //
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterUpdate hook is triggered after successfully updating // OnModelAfterUpdate hook is triggered after successfully updating
// existing entry in the DB. // existing entry in the DB.
OnModelAfterUpdate() *hook.Hook[*ModelEvent] //
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelBeforeDelete hook is triggered before deleting an // OnModelBeforeDelete hook is triggered before deleting an
// existing entry from the DB. // existing entry from the DB.
OnModelBeforeDelete() *hook.Hook[*ModelEvent] //
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
// OnModelAfterDelete is triggered after successfully deleting an // OnModelAfterDelete is triggered after successfully deleting an
// existing entry from the DB. // existing entry from the DB.
OnModelAfterDelete() *hook.Hook[*ModelEvent] //
// If the optional "tags" list (table names and/or the Collection id for Record models)
// is specified, then all event handlers registered via the created hook
// will be triggered and called only if their event data origin matches the tags.
OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Mailer event hooks // Mailer event hooks
@@ -126,38 +185,62 @@ type App interface {
// admin password reset email was successfully sent. // admin password reset email was successfully sent.
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerBeforeUserResetPasswordSend hook is triggered right before // OnMailerBeforeRecordResetPasswordSend hook is triggered right before
// sending a password reset email to a user. // sending a password reset email to an auth record.
// //
// Could be used to send your own custom email template if // 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.
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerAfterUserResetPasswordSend hook is triggered after // OnMailerAfterRecordResetPasswordSend hook is triggered after
// a user password reset email was successfully sent. // an auth record password reset email was successfully sent.
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeUserVerificationSend hook is triggered right before // OnMailerBeforeRecordVerificationSend hook is triggered right before
// sending a verification email to a user. // sending a verification email to an auth record.
// //
// Could be used to send your own custom email template if // 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.
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerAfterUserVerificationSend hook is triggered after a user // OnMailerAfterRecordVerificationSend hook is triggered after a
// verification email was successfully sent. // verification email was successfully sent to an auth record.
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerBeforeUserChangeEmailSend hook is triggered right before // OnMailerBeforeRecordChangeEmailSend hook is triggered right before
// sending a confirmation new address email to a a user. // sending a confirmation new address email to an auth record.
// //
// Could be used to send your own custom email template if // 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.
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// OnMailerAfterUserChangeEmailSend hook is triggered after a user // OnMailerAfterRecordChangeEmailSend hook is triggered after a
// change address email was successfully sent. // verification email was successfully sent to an auth record.
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Realtime API event hooks // Realtime API event hooks
@@ -167,6 +250,21 @@ type App interface {
// the SSE client connection. // the SSE client connection.
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent] 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 // OnRealtimeBeforeSubscribeRequest hook is triggered before changing
// the client subscriptions, allowing you to further validate and // the client subscriptions, allowing you to further validate and
// modify the submitted change. // modify the submitted change.
@@ -207,7 +305,7 @@ type App interface {
// //
// Could be used to validate or modify the file response before // Could be used to validate or modify the file response before
// returning it to the client. // returning it to the client.
OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Admin API event hooks // Admin API event hooks
@@ -263,122 +361,345 @@ type App interface {
// authenticated admin data and token. // authenticated admin data and token.
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
// --------------------------------------------------------------- // OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin
// User API event hooks // auth with password API request (after request data load and before password validation).
// ---------------------------------------------------------------
// OnUsersListRequest hook is triggered on each API Users list request.
// //
// Could be used to validate or modify the response before returning it to the client. // Could be used to implement for example a custom password validation
OnUsersListRequest() *hook.Hook[*UsersListEvent] // or to locate a different Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]).
OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
// OnUserViewRequest hook is triggered on each API User view request. // OnAdminAfterAuthWithPasswordRequest hook is triggered after each
// successful Admin auth with password API request.
OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
// OnAdminBeforeAuthRefreshRequest hook is triggered before each Admin
// auth refresh API request (right before generating a new auth token).
// //
// Could be used to validate or modify the response before returning it to the client. // Could be used to additionally validate the request data or implement
OnUserViewRequest() *hook.Hook[*UserViewEvent] // completely different auth refresh behavior (returning [hook.StopPropagation]).
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
// OnUserBeforeCreateRequest hook is triggered before each API User // OnAdminAfterAuthRefreshRequest hook is triggered after each
// create request (after request data load and before model persistence). // successful auth refresh API request (right after generating a new auth token).
OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
// OnAdminBeforeRequestPasswordResetRequest hook is triggered before each Admin
// 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]).
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
// successful request password reset API request.
OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
// OnAdminBeforeConfirmPasswordResetRequest hook is triggered before each Admin
// confirm password reset API request (after request data load and before persistence).
// //
// Could be used to additionally validate the request data or implement // 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]).
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// OnUserAfterCreateRequest hook is triggered after each // OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
// successful API User create request. // successful confirm password reset API request.
OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent] OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
// OnUserBeforeUpdateRequest hook is triggered before each API User // ---------------------------------------------------------------
// update request (after request data load and before model persistence). // Record Auth API event hooks
// ---------------------------------------------------------------
// 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.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent]
// OnRecordBeforeAuthWithPasswordRequest hook is triggered before each Record
// auth with password API request (after request data load and before password validation).
//
// Could be used to implement for example a custom password validation
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
// OnRecordAfterAuthWithPasswordRequest hook is triggered after each
// successful Record auth with password API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
//
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
// request will try to create a new auth Record.
//
// To assign or link a different existing record model you can
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
// OnRecordAfterAuthWithOAuth2Request hook is triggered after each
// successful Record OAuth2 API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
// OnRecordBeforeAuthRefreshRequest hook is triggered before each Record
// auth refresh API request (right before generating a new auth token).
//
// Could be used to additionally validate the request data or implement
// completely different auth refresh behavior (returning [hook.StopPropagation]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
// OnRecordAfterAuthRefreshRequest hook is triggered after each
// successful auth refresh API request (right after generating a new auth token).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
// 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]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
// successful request password reset API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*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 // 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]).
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
// OnUserAfterUpdateRequest hook is triggered after each // OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
// successful API User update request. // successful confirm password reset API request.
OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
// OnUserBeforeDeleteRequest hook is triggered before each API User // OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
// delete request (after model load and before actual deletion). // 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]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
// OnRecordAfterRequestVerificationRequest hook is triggered after each
// successful request verification API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*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 // Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]). // completely different persistence 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 // If the optional "tags" list (Collection ids or names) is specified,
// authenticated user data and token. // then all event handlers registered via the created hook will be
OnUserAuthRequest() *hook.Hook[*UserAuthEvent] // triggered and called only if their event data origin matches the tags.
OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
// OnUserListExternalAuths hook is triggered on each API user's external auths list request. // OnRecordAfterConfirmVerificationRequest hook is triggered after each
// successful confirm verification API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*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]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
// successful request email change API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*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]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
// successful confirm email change API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
// //
// Could be used to validate or modify the response before returning it to the client. // Could be used to validate or modify the response before returning it to the client.
OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
// OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's // OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion). // external auth unlink request (after models load and before the actual relation deletion).
// //
// Could be used to additionally validate the request data or implement // 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]).
OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// OnUserAfterUnlinkExternalAuthRequest hook is triggered after each // OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API user's external auth unlink request. // successful API record external auth unlink request.
OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Record API event hooks // Record CRUD API event hooks
// --------------------------------------------------------------- // ---------------------------------------------------------------
// OnRecordsListRequest hook is triggered on each API Records list request. // OnRecordsListRequest hook is triggered on each API Records list request.
// //
// Could be used to validate or modify the response before returning it to the client. // Could be used to validate or modify the response before returning it to the client.
OnRecordsListRequest() *hook.Hook[*RecordsListEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent]
// OnRecordViewRequest hook is triggered on each API Record view request. // OnRecordViewRequest hook is triggered on each API Record view request.
// //
// Could be used to validate or modify the response before returning it to the client. // Could be used to validate or modify the response before returning it to the client.
OnRecordViewRequest() *hook.Hook[*RecordViewEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent]
// OnRecordBeforeCreateRequest hook is triggered before each API Record // OnRecordBeforeCreateRequest hook is triggered before each API Record
// create request (after request data load and before model persistence). // create request (after request data load and before model persistence).
// //
// Could be used to additionally validate the request data or implement // 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] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
// OnRecordAfterCreateRequest hook is triggered after each // OnRecordAfterCreateRequest hook is triggered after each
// successful API Record create request. // successful API Record create request.
OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
// OnRecordBeforeUpdateRequest hook is triggered before each API Record // OnRecordBeforeUpdateRequest hook is triggered before each API Record
// update request (after request data load and before model persistence). // update request (after request data load and before model persistence).
// //
// Could be used to additionally validate the request data or implement // 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] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
// OnRecordAfterUpdateRequest hook is triggered after each // OnRecordAfterUpdateRequest hook is triggered after each
// successful API Record update request. // successful API Record update request.
OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
// OnRecordBeforeDeleteRequest hook is triggered before each API Record // OnRecordBeforeDeleteRequest hook is triggered before each API Record
// delete request (after model load and before actual deletion). // delete request (after model load and before actual deletion).
// //
// Could be used to additionally validate the request data or implement // 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] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
// OnRecordAfterDeleteRequest hook is triggered after each // OnRecordAfterDeleteRequest hook is triggered after each
// successful API Record delete request. // successful API Record delete request.
OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] //
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Collection API event hooks // Collection API event hooks
+494 -246
View File
File diff suppressed because it is too large. Load diff
+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)
}
}
}
+39 -243
View File
@@ -11,7 +11,11 @@ func TestNewBaseApp(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir) defer os.RemoveAll(testDataDir)
app := NewBaseApp(testDataDir, "test_env", true) app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "test_env",
IsDebug: true,
})
if app.dataDir != testDataDir { if app.dataDir != testDataDir {
t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir) t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir)
@@ -42,14 +46,26 @@ func TestBaseAppBootstrap(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir) defer os.RemoveAll(testDataDir)
app := NewBaseApp(testDataDir, "pb_test_env", false) app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
defer app.ResetBootstrapState() defer app.ResetBootstrapState()
if app.IsBootstrapped() {
t.Fatal("Didn't expect the application to be bootstrapped.")
}
// bootstrap // bootstrap
if err := app.Bootstrap(); err != nil { if err := app.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !app.IsBootstrapped() {
t.Fatal("Expected the application to be bootstrapped.")
}
if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() { if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() {
t.Fatal("Expected test data directory to be created.") t.Fatal("Expected test data directory to be created.")
} }
@@ -112,29 +128,33 @@ func TestBaseAppGetters(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir) defer os.RemoveAll(testDataDir)
app := NewBaseApp(testDataDir, "pb_test_env", false) app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
defer app.ResetBootstrapState() defer app.ResetBootstrapState()
if err := app.Bootstrap(); err != nil { if err := app.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if app.db != app.DB() {
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.db)
}
if app.dao != app.Dao() { if app.dao != app.Dao() {
t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao) t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao)
} }
if app.logsDB != app.LogsDB() { if app.dao.ConcurrentDB() != app.DB() {
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDB) t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.dao.ConcurrentDB())
} }
if app.logsDao != app.LogsDao() { if app.logsDao != app.LogsDao() {
t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao) t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao)
} }
if app.logsDao.ConcurrentDB() != app.LogsDB() {
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDao.ConcurrentDB())
}
if app.dataDir != app.DataDir() { if app.dataDir != app.DataDir() {
t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir) t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir)
} }
@@ -162,245 +182,17 @@ func TestBaseAppGetters(t *testing.T) {
if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil { if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil {
t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe) t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe)
} }
if app.onModelBeforeCreate != app.OnModelBeforeCreate() || app.OnModelBeforeCreate() == nil {
t.Fatalf("Getter app.OnModelBeforeCreate does not match or nil (%v vs %v)", app.OnModelBeforeCreate(), app.onModelBeforeCreate)
}
if app.onModelAfterCreate != app.OnModelAfterCreate() || app.OnModelAfterCreate() == nil {
t.Fatalf("Getter app.OnModelAfterCreate does not match or nil (%v vs %v)", app.OnModelAfterCreate(), app.onModelAfterCreate)
}
if app.onModelBeforeUpdate != app.OnModelBeforeUpdate() || app.OnModelBeforeUpdate() == nil {
t.Fatalf("Getter app.OnModelBeforeUpdate does not match or nil (%v vs %v)", app.OnModelBeforeUpdate(), app.onModelBeforeUpdate)
}
if app.onModelAfterUpdate != app.OnModelAfterUpdate() || app.OnModelAfterUpdate() == nil {
t.Fatalf("Getter app.OnModelAfterUpdate does not match or nil (%v vs %v)", app.OnModelAfterUpdate(), app.onModelAfterUpdate)
}
if app.onModelBeforeDelete != app.OnModelBeforeDelete() || app.OnModelBeforeDelete() == nil {
t.Fatalf("Getter app.OnModelBeforeDelete does not match or nil (%v vs %v)", app.OnModelBeforeDelete(), app.onModelBeforeDelete)
}
if app.onModelAfterDelete != app.OnModelAfterDelete() || app.OnModelAfterDelete() == nil {
t.Fatalf("Getter app.OnModelAfterDelete does not match or nil (%v vs %v)", app.OnModelAfterDelete(), app.onModelAfterDelete)
}
if app.onMailerBeforeAdminResetPasswordSend != app.OnMailerBeforeAdminResetPasswordSend() || app.OnMailerBeforeAdminResetPasswordSend() == nil {
t.Fatalf("Getter app.OnMailerBeforeAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeAdminResetPasswordSend(), app.onMailerBeforeAdminResetPasswordSend)
}
if app.onMailerAfterAdminResetPasswordSend != app.OnMailerAfterAdminResetPasswordSend() || app.OnMailerAfterAdminResetPasswordSend() == nil {
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.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.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.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.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.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.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
t.Fatalf("Getter app.OnRealtimeConnectRequest does not match or nil (%v vs %v)", app.OnRealtimeConnectRequest(), app.onRealtimeConnectRequest)
}
if app.onRealtimeBeforeSubscribeRequest != app.OnRealtimeBeforeSubscribeRequest() || app.OnRealtimeBeforeSubscribeRequest() == nil {
t.Fatalf("Getter app.OnRealtimeBeforeSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeBeforeSubscribeRequest(), app.onRealtimeBeforeSubscribeRequest)
}
if app.onRealtimeAfterSubscribeRequest != app.OnRealtimeAfterSubscribeRequest() || app.OnRealtimeAfterSubscribeRequest() == nil {
t.Fatalf("Getter app.OnRealtimeAfterSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeAfterSubscribeRequest(), app.onRealtimeAfterSubscribeRequest)
}
if app.onSettingsListRequest != app.OnSettingsListRequest() || app.OnSettingsListRequest() == nil {
t.Fatalf("Getter app.OnSettingsListRequest does not match or nil (%v vs %v)", app.OnSettingsListRequest(), app.onSettingsListRequest)
}
if app.onSettingsBeforeUpdateRequest != app.OnSettingsBeforeUpdateRequest() || app.OnSettingsBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnSettingsBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsBeforeUpdateRequest(), app.onSettingsBeforeUpdateRequest)
}
if app.onSettingsAfterUpdateRequest != app.OnSettingsAfterUpdateRequest() || app.OnSettingsAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnSettingsAfterUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsAfterUpdateRequest(), app.onSettingsAfterUpdateRequest)
}
if app.onFileDownloadRequest != app.OnFileDownloadRequest() || app.OnFileDownloadRequest() == nil {
t.Fatalf("Getter app.OnFileDownloadRequest does not match or nil (%v vs %v)", app.OnFileDownloadRequest(), app.onFileDownloadRequest)
}
if app.onAdminsListRequest != app.OnAdminsListRequest() || app.OnAdminsListRequest() == nil {
t.Fatalf("Getter app.OnAdminsListRequest does not match or nil (%v vs %v)", app.OnAdminsListRequest(), app.onAdminsListRequest)
}
if app.onAdminViewRequest != app.OnAdminViewRequest() || app.OnAdminViewRequest() == nil {
t.Fatalf("Getter app.OnAdminViewRequest does not match or nil (%v vs %v)", app.OnAdminViewRequest(), app.onAdminViewRequest)
}
if app.onAdminBeforeCreateRequest != app.OnAdminBeforeCreateRequest() || app.OnAdminBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnAdminBeforeCreateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeCreateRequest(), app.onAdminBeforeCreateRequest)
}
if app.onAdminAfterCreateRequest != app.OnAdminAfterCreateRequest() || app.OnAdminAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnAdminAfterCreateRequest does not match or nil (%v vs %v)", app.OnAdminAfterCreateRequest(), app.onAdminAfterCreateRequest)
}
if app.onAdminBeforeUpdateRequest != app.OnAdminBeforeUpdateRequest() || app.OnAdminBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnAdminBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeUpdateRequest(), app.onAdminBeforeUpdateRequest)
}
if app.onAdminAfterUpdateRequest != app.OnAdminAfterUpdateRequest() || app.OnAdminAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnAdminAfterUpdateRequest does not match or nil (%v vs %v)", app.OnAdminAfterUpdateRequest(), app.onAdminAfterUpdateRequest)
}
if app.onAdminBeforeDeleteRequest != app.OnAdminBeforeDeleteRequest() || app.OnAdminBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnAdminBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnAdminBeforeDeleteRequest(), app.onAdminBeforeDeleteRequest)
}
if app.onAdminAfterDeleteRequest != app.OnAdminAfterDeleteRequest() || app.OnAdminAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnAdminAfterDeleteRequest does not match or nil (%v vs %v)", app.OnAdminAfterDeleteRequest(), app.onAdminAfterDeleteRequest)
}
if app.onAdminAuthRequest != app.OnAdminAuthRequest() || app.OnAdminAuthRequest() == nil {
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.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.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.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.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.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.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.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.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.onUserListExternalAuths != app.OnUserListExternalAuths() || app.OnUserListExternalAuths() == nil {
t.Fatalf("Getter app.OnUserListExternalAuths does not match or nil (%v vs %v)", app.OnUserListExternalAuths(), app.onUserListExternalAuths)
}
if app.onUserBeforeUnlinkExternalAuthRequest != app.OnUserBeforeUnlinkExternalAuthRequest() || app.OnUserBeforeUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnUserBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserBeforeUnlinkExternalAuthRequest(), app.onUserBeforeUnlinkExternalAuthRequest)
}
if app.onUserAfterUnlinkExternalAuthRequest != app.OnUserAfterUnlinkExternalAuthRequest() || app.OnUserAfterUnlinkExternalAuthRequest() == nil {
t.Fatalf("Getter app.OnUserAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserAfterUnlinkExternalAuthRequest(), app.onUserAfterUnlinkExternalAuthRequest)
}
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.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.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.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.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.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.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.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.onCollectionsListRequest != app.OnCollectionsListRequest() || app.OnCollectionsListRequest() == nil {
t.Fatalf("Getter app.OnCollectionsListRequest does not match or nil (%v vs %v)", app.OnCollectionsListRequest(), app.onCollectionsListRequest)
}
if app.onCollectionViewRequest != app.OnCollectionViewRequest() || app.OnCollectionViewRequest() == nil {
t.Fatalf("Getter app.OnCollectionViewRequest does not match or nil (%v vs %v)", app.OnCollectionViewRequest(), app.onCollectionViewRequest)
}
if app.onCollectionBeforeCreateRequest != app.OnCollectionBeforeCreateRequest() || app.OnCollectionBeforeCreateRequest() == nil {
t.Fatalf("Getter app.OnCollectionBeforeCreateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeCreateRequest(), app.onCollectionBeforeCreateRequest)
}
if app.onCollectionAfterCreateRequest != app.OnCollectionAfterCreateRequest() || app.OnCollectionAfterCreateRequest() == nil {
t.Fatalf("Getter app.OnCollectionAfterCreateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterCreateRequest(), app.onCollectionAfterCreateRequest)
}
if app.onCollectionBeforeUpdateRequest != app.OnCollectionBeforeUpdateRequest() || app.OnCollectionBeforeUpdateRequest() == nil {
t.Fatalf("Getter app.OnCollectionBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeUpdateRequest(), app.onCollectionBeforeUpdateRequest)
}
if app.onCollectionAfterUpdateRequest != app.OnCollectionAfterUpdateRequest() || app.OnCollectionAfterUpdateRequest() == nil {
t.Fatalf("Getter app.OnCollectionAfterUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterUpdateRequest(), app.onCollectionAfterUpdateRequest)
}
if app.onCollectionBeforeDeleteRequest != app.OnCollectionBeforeDeleteRequest() || app.OnCollectionBeforeDeleteRequest() == nil {
t.Fatalf("Getter app.OnCollectionBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeDeleteRequest(), app.onCollectionBeforeDeleteRequest)
}
if app.onCollectionAfterDeleteRequest != app.OnCollectionAfterDeleteRequest() || app.OnCollectionAfterDeleteRequest() == nil {
t.Fatalf("Getter app.OnCollectionAfterDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionAfterDeleteRequest(), app.onCollectionAfterDeleteRequest)
}
} }
func TestBaseAppNewMailClient(t *testing.T) { func TestBaseAppNewMailClient(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir) defer os.RemoveAll(testDataDir)
app := NewBaseApp(testDataDir, "pb_test_env", false) app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
client1 := app.NewMailClient() client1 := app.NewMailClient()
if val, ok := client1.(*mailer.Sendmail); !ok { if val, ok := client1.(*mailer.Sendmail); !ok {
@@ -419,7 +211,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/" const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir) defer os.RemoveAll(testDataDir)
app := NewBaseApp(testDataDir, "pb_test_env", false) app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local // local
local, localErr := app.NewFilesystem() local, localErr := app.NewFilesystem()
+20
View File
@@ -0,0 +1,20 @@
package core
import (
"github.com/pocketbase/dbx"
)
func initPragmas(db *dbx.DB) error {
// note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection
_, err := db.NewQuery(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = TRUE;
`).Execute()
return err
}
+9 -13
View File
@@ -3,24 +3,20 @@
package core package core
import ( import (
"fmt"
_ "github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
_ "github.com/mattn/go-sqlite3"
) )
func connectDB(dbPath string) (*dbx.DB, error) { func connectDB(dbPath string) (*dbx.DB, error) {
pragmas := "_foreign_keys=1&_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=8000" db, err := dbx.Open("sqlite3", dbPath)
if err != nil {
db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas)) return nil, err
if openErr != nil {
return nil, openErr
} }
// additional pragmas not supported through the dsn string if err := initPragmas(db); err != nil {
_, err := db.NewQuery(` db.Close()
pragma journal_size_limit = 100000000; return nil, err
`).Execute() }
return db, err return db, nil
} }
+10 -4
View File
@@ -3,14 +3,20 @@
package core package core
import ( import (
"fmt"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
func connectDB(dbPath string) (*dbx.DB, error) { 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)" db, err := dbx.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
return dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas)) if err := initPragmas(db); err != nil {
db.Close()
return nil, err
}
return db, nil
} }
+200 -52
View File
@@ -4,6 +4,10 @@ import (
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
@@ -11,36 +15,85 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
type BaseCollectionEvent struct {
Collection *models.Collection
}
func (e *BaseCollectionEvent) Tags() []string {
if e.Collection == nil {
return nil
}
tags := make([]string, 0, 2)
if e.Collection.Id != "" {
tags = append(tags, e.Collection.Id)
}
if e.Collection.Name != "" {
tags = append(tags, e.Collection.Name)
}
return tags
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Serve events data // Serve events data
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type BootstrapEvent struct {
App App
}
type ServeEvent struct { type ServeEvent struct {
App App App App
Router *echo.Echo Router *echo.Echo
} }
type ApiErrorEvent struct {
HttpContext echo.Context
Error error
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Model DAO events data // Model DAO events data
// ------------------------------------------------------------------- // -------------------------------------------------------------------
var _ hook.Tagger = (*ModelEvent)(nil)
type ModelEvent struct { type ModelEvent struct {
Dao *daos.Dao Dao *daos.Dao
Model models.Model Model models.Model
} }
func (e *ModelEvent) Tags() []string {
if e.Model == nil {
return nil
}
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
return []string{r.Collection().Id, r.Collection().Name}
}
return []string{e.Model.TableName()}
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Mailer events data // Mailer events data
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type MailerUserEvent struct { type MailerRecordEvent struct {
BaseCollectionEvent
MailClient mailer.Mailer MailClient mailer.Mailer
User *models.User Message *mailer.Message
Record *models.Record
Meta map[string]any Meta map[string]any
} }
type MailerAdminEvent struct { type MailerAdminEvent struct {
MailClient mailer.Mailer MailClient mailer.Mailer
Message *mailer.Message
Admin *models.Admin Admin *models.Admin
Meta map[string]any Meta map[string]any
} }
@@ -54,6 +107,17 @@ type RealtimeConnectEvent struct {
Client subscriptions.Client 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 { type RealtimeSubscribeEvent struct {
HttpContext echo.Context HttpContext echo.Context
Client subscriptions.Client Client subscriptions.Client
@@ -66,44 +130,150 @@ type RealtimeSubscribeEvent struct {
type SettingsListEvent struct { type SettingsListEvent struct {
HttpContext echo.Context HttpContext echo.Context
RedactedSettings *Settings RedactedSettings *settings.Settings
} }
type SettingsUpdateEvent struct { type SettingsUpdateEvent struct {
HttpContext echo.Context HttpContext echo.Context
OldSettings *Settings OldSettings *settings.Settings
NewSettings *Settings NewSettings *settings.Settings
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Record API events data // Record CRUD API events data
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type RecordsListEvent struct { type RecordsListEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Collection *models.Collection
Records []*models.Record Records []*models.Record
Result *search.Result Result *search.Result
} }
type RecordViewEvent struct { type RecordViewEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Record *models.Record Record *models.Record
} }
type RecordCreateEvent struct { type RecordCreateEvent struct {
HttpContext echo.Context BaseCollectionEvent
Record *models.Record
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
} }
type RecordUpdateEvent struct { type RecordUpdateEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
UploadedFiles map[string][]*filesystem.File
}
type RecordDeleteEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Record *models.Record Record *models.Record
} }
type RecordDeleteEvent struct { // -------------------------------------------------------------------
// Auth Record API events data
// -------------------------------------------------------------------
type RecordAuthEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Record *models.Record Record *models.Record
Token string
Meta any
}
type RecordAuthWithPasswordEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
Identity string
Password string
}
type RecordAuthWithOAuth2Event struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
OAuth2User *auth.AuthUser
}
type RecordAuthRefreshEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordRequestPasswordResetEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmPasswordResetEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordRequestVerificationEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmVerificationEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordRequestEmailChangeEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmEmailChangeEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
}
type RecordListExternalAuthsEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
ExternalAuths []*models.ExternalAuth
}
type RecordUnlinkExternalAuthEvent struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
ExternalAuth *models.ExternalAuth
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -142,53 +312,26 @@ type AdminAuthEvent struct {
Token string Token string
} }
// ------------------------------------------------------------------- type AdminAuthWithPasswordEvent struct {
// User API events data
// -------------------------------------------------------------------
type UsersListEvent struct {
HttpContext echo.Context HttpContext echo.Context
Users []*models.User Admin *models.Admin
Result *search.Result Identity string
Password string
} }
type UserViewEvent struct { type AdminAuthRefreshEvent struct {
HttpContext echo.Context HttpContext echo.Context
User *models.User Admin *models.Admin
} }
type UserCreateEvent struct { type AdminRequestPasswordResetEvent struct {
HttpContext echo.Context HttpContext echo.Context
User *models.User Admin *models.Admin
} }
type UserUpdateEvent struct { type AdminConfirmPasswordResetEvent struct {
HttpContext echo.Context HttpContext echo.Context
User *models.User Admin *models.Admin
}
type UserDeleteEvent struct {
HttpContext echo.Context
User *models.User
}
type UserAuthEvent struct {
HttpContext echo.Context
User *models.User
Token string
Meta any
}
type UserListExternalAuthsEvent struct {
HttpContext echo.Context
User *models.User
ExternalAuths []*models.ExternalAuth
}
type UserUnlinkExternalAuthEvent struct {
HttpContext echo.Context
User *models.User
ExternalAuth *models.ExternalAuth
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -202,23 +345,27 @@ type CollectionsListEvent struct {
} }
type CollectionViewEvent struct { type CollectionViewEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Collection *models.Collection
} }
type CollectionCreateEvent struct { type CollectionCreateEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Collection *models.Collection
} }
type CollectionUpdateEvent struct { type CollectionUpdateEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Collection *models.Collection
} }
type CollectionDeleteEvent struct { type CollectionDeleteEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Collection *models.Collection
} }
type CollectionsImportEvent struct { type CollectionsImportEvent struct {
@@ -231,8 +378,9 @@ type CollectionsImportEvent struct {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type FileDownloadEvent struct { type FileDownloadEvent struct {
BaseCollectionEvent
HttpContext echo.Context HttpContext echo.Context
Collection *models.Collection
Record *models.Record Record *models.Record
FileField *schema.SchemaField FileField *schema.SchemaField
ServedPath string ServedPath string
+84
View File
@@ -0,0 +1,84 @@
package core_test
import (
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestBaseCollectionEventTags(t *testing.T) {
c1 := new(models.Collection)
c2 := new(models.Collection)
c2.Id = "a"
c3 := new(models.Collection)
c3.Name = "b"
c4 := new(models.Collection)
c4.Id = "a"
c4.Name = "b"
scenarios := []struct {
collection *models.Collection
expectedTags []string
}{
{c1, []string{}},
{c2, []string{"a"}},
{c3, []string{"b"}},
{c4, []string{"a", "b"}},
}
for i, s := range scenarios {
event := new(core.BaseCollectionEvent)
event.Collection = s.collection
tags := event.Tags()
if len(s.expectedTags) != len(tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
for _, tag := range s.expectedTags {
if !list.ExistInSlice(tag, tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
}
}
}
func TestModelEventTags(t *testing.T) {
m1 := new(models.Admin)
c := new(models.Collection)
c.Id = "a"
c.Name = "b"
m2 := models.NewRecord(c)
scenarios := []struct {
model models.Model
expectedTags []string
}{
{m1, []string{"_admins"}},
{m2, []string{"a", "b"}},
}
for i, s := range scenarios {
event := new(core.ModelEvent)
event.Model = s.model
tags := event.Tags()
if len(s.expectedTags) != len(tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
for _, tag := range s.expectedTags {
if !list.ExistInSlice(tag, tags) {
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
}
}
}
}
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security" "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. // Returns an error if the JWT token is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) { func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
// @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token) unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -86,20 +88,22 @@ func (dao *Dao) TotalAdmins() (int, error) {
// IsAdminEmailUnique checks if the provided email address is not // IsAdminEmailUnique checks if the provided email address is not
// already in use by other admins. // 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 == "" { if email == "" {
return false return false
} }
var exists bool query := dao.AdminQuery().Select("count(*)").
err := dao.AdminQuery().
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.HashExp{"email": email}). AndWhere(dbx.HashExp{"email": email}).
Limit(1). Limit(1)
Row(&exists)
return err == nil && !exists if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
return query.Row(&exists) == nil && !exists
} }
// DeleteAdmin deletes the provided Admin model. // DeleteAdmin deletes the provided Admin model.
+32 -12
View File
@@ -27,8 +27,9 @@ func TestFindAdminById(t *testing.T) {
id string id string
expectError bool expectError bool
}{ }{
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true}, {" ", true},
{"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false}, {"missing", true},
{"9q2trqumvlyr3bd", false},
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
@@ -53,6 +54,7 @@ func TestFindAdminByEmail(t *testing.T) {
email string email string
expectError bool expectError bool
}{ }{
{"", true},
{"invalid", true}, {"invalid", true},
{"missing@example.com", true}, {"missing@example.com", true},
{"test@example.com", false}, {"test@example.com", false},
@@ -83,23 +85,30 @@ func TestFindAdminByToken(t *testing.T) {
expectedEmail string expectedEmail string
expectError bool expectError bool
}{ }{
// invalid base key (password reset key for auth token) // invalid auth token
{ {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
app.Settings().AdminPasswordResetToken.Secret, app.Settings().AdminAuthToken.Secret,
"", "",
true, true,
}, },
// expired token // expired token
{ {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
app.Settings().AdminAuthToken.Secret, app.Settings().AdminAuthToken.Secret,
"", "",
true, true,
}, },
// wrong base token (password reset token secret instead of auth secret)
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
app.Settings().AdminPasswordResetToken.Secret,
"",
true,
},
// valid token // valid token
{ {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
app.Settings().AdminAuthToken.Secret, app.Settings().AdminAuthToken.Secret,
"test@example.com", "test@example.com",
false, false,
@@ -129,8 +138,8 @@ func TestTotalAdmins(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result1 != 2 { if result1 != 3 {
t.Fatalf("Expected 2 admins, got %d", result1) t.Fatalf("Expected 3 admins, got %d", result1)
} }
// delete all // delete all
@@ -156,8 +165,10 @@ func TestIsAdminEmailUnique(t *testing.T) {
}{ }{
{"", "", false}, {"", "", false},
{"test@example.com", "", false}, {"test@example.com", "", false},
{"test2@example.com", "", false},
{"test3@example.com", "", false},
{"new@example.com", "", true}, {"new@example.com", "", true},
{"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true}, {"test@example.com", "sywbhecnh46rhm0", true},
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
@@ -186,15 +197,24 @@ func TestDeleteAdmin(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
admin3, err := app.Dao().FindAdminByEmail("test3@example.com")
if err != nil {
t.Fatal(err)
}
deleteErr1 := app.Dao().DeleteAdmin(admin1) deleteErr1 := app.Dao().DeleteAdmin(admin1)
if deleteErr1 != nil { if deleteErr1 != nil {
t.Fatal(deleteErr1) t.Fatal(deleteErr1)
} }
// cannot delete the only remaining admin
deleteErr2 := app.Dao().DeleteAdmin(admin2) 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") t.Fatal("Expected delete error, got nil")
} }
+112 -34
View File
@@ -5,23 +5,43 @@ package daos
import ( import (
"errors" "errors"
"fmt" "time"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
) )
// New creates a new Dao instance with the provided db builder. // New creates a new Dao instance with the provided db builder
// (for both async and sync db operations).
func New(db dbx.Builder) *Dao { func New(db dbx.Builder) *Dao {
return NewMultiDB(db, db)
}
// New creates a new Dao instance with the provided dedicated
// async and sync db builders.
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
return &Dao{ return &Dao{
db: db, concurrentDB: concurrentDB,
nonconcurrentDB: nonconcurrentDB,
MaxLockRetries: 8,
ModelQueryTimeout: 30 * time.Second,
} }
} }
// Dao handles various db operations. // Dao handles various db operations.
// Think of Dao as a repository and service layer in one. // Think of Dao as a repository and service layer in one.
type Dao struct { type Dao struct {
db dbx.Builder // in a transaction both refer to the same *dbx.TX instance
concurrentDB dbx.Builder
nonconcurrentDB dbx.Builder
// MaxLockRetries specifies the default max "database is locked" auto retry attempts.
MaxLockRetries int
// ModelQueryTimeout is the default max duration of a running ModelQuery().
//
// This field has no effect if an explicit query context is already specified.
ModelQueryTimeout time.Duration
BeforeCreateFunc func(eventDao *Dao, m models.Model) error BeforeCreateFunc func(eventDao *Dao, m models.Model) error
AfterCreateFunc func(eventDao *Dao, m models.Model) AfterCreateFunc func(eventDao *Dao, m models.Model)
@@ -31,16 +51,40 @@ type Dao struct {
AfterDeleteFunc func(eventDao *Dao, m models.Model) AfterDeleteFunc func(eventDao *Dao, m models.Model)
} }
// DB returns the internal db builder (*dbx.DB or *dbx.TX). // DB returns the default dao db builder (*dbx.DB or *dbx.TX).
//
// Currently the default db builder is dao.concurrentDB but that may change in the future.
func (dao *Dao) DB() dbx.Builder { func (dao *Dao) DB() dbx.Builder {
return dao.db return dao.ConcurrentDB()
} }
// ModelQuery creates a new query with preset Select and From fields // ConcurrentDB returns the dao concurrent (aka. multiple open connections)
// based on the provided model argument. // db builder (*dbx.DB or *dbx.TX).
//
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
func (dao *Dao) ConcurrentDB() dbx.Builder {
return dao.concurrentDB
}
// NonconcurrentDB returns the dao nonconcurrent (aka. single open connection)
// db builder (*dbx.DB or *dbx.TX).
//
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
func (dao *Dao) NonconcurrentDB() dbx.Builder {
return dao.nonconcurrentDB
}
// ModelQuery creates a new preconfigured select query with preset
// SELECT, FROM and other common fields based on the provided model.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery { func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
tableName := m.TableName() tableName := m.TableName()
return dao.db.Select(fmt.Sprintf("{{%s}}.*", tableName)).From(tableName)
return dao.DB().
Select("{{" + tableName + "}}.*").
From(tableName).
WithBuildHook(func(query *dbx.Query) {
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries))
})
} }
// FindById finds a single db record with the specified id and // FindById finds a single db record with the specified id and
@@ -57,13 +101,23 @@ type afterCallGroup struct {
// RunInTransaction wraps fn into a transaction. // RunInTransaction wraps fn into a transaction.
// //
// It is safe to nest RunInTransaction calls. // It is safe to nest RunInTransaction calls as long as you use the txDao.
func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error { func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
switch txOrDB := dao.db.(type) { switch txOrDB := dao.NonconcurrentDB().(type) {
case *dbx.Tx: case *dbx.Tx:
// nested transactions are not supported by default // nested transactions are not supported by default
// so execute the function within the current transaction // 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: case *dbx.DB:
afterCalls := []afterCallGroup{} afterCalls := []afterCallGroup{}
@@ -107,14 +161,15 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
if txError == nil { if txError == nil {
// execute after event calls on successful transaction // execute after event calls on successful transaction
// (note: using the non-transaction dao to allow following queries in the after hooks)
for _, call := range afterCalls { for _, call := range afterCalls {
switch call.Action { switch call.Action {
case "create": case "create":
dao.AfterCreateFunc(call.EventDao, call.Model) dao.AfterCreateFunc(dao, call.Model)
case "update": case "update":
dao.AfterUpdateFunc(call.EventDao, call.Model) dao.AfterUpdateFunc(dao, call.Model)
case "delete": case "delete":
dao.AfterDeleteFunc(call.EventDao, call.Model) dao.AfterDeleteFunc(dao, call.Model)
} }
} }
} }
@@ -122,7 +177,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
return txError return txError
} }
return errors.New("Failed to start transaction (unknown dao.db)") return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
} }
// Delete deletes the provided model. // Delete deletes the provided model.
@@ -131,30 +186,36 @@ func (dao *Dao) Delete(m models.Model) error {
return errors.New("ID is not set") return errors.New("ID is not set")
} }
if dao.BeforeDeleteFunc != nil { return dao.lockRetry(func(retryDao *Dao) error {
if err := dao.BeforeDeleteFunc(dao, m); err != nil { if retryDao.BeforeDeleteFunc != nil {
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
return err
}
}
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
return err return err
} }
}
if err := dao.db.Model(m).Delete(); err != nil { if retryDao.AfterDeleteFunc != nil {
return err retryDao.AfterDeleteFunc(retryDao, m)
} }
if dao.AfterDeleteFunc != nil { return nil
dao.AfterDeleteFunc(dao, m) })
}
return nil
} }
// Save upserts (update or create if primary key is not set) the provided model. // Save upserts (update or create if primary key is not set) the provided model.
func (dao *Dao) Save(m models.Model) error { func (dao *Dao) Save(m models.Model) error {
if m.IsNew() { if m.IsNew() {
return dao.create(m) return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.create(m)
})
} }
return dao.update(m) return dao.lockRetry(func(retryDao *Dao) error {
return retryDao.update(m)
})
} }
func (dao *Dao) update(m models.Model) error { func (dao *Dao) update(m models.Model) error {
@@ -177,7 +238,7 @@ func (dao *Dao) update(m models.Model) error {
if v, ok := any(m).(models.ColumnValueMapper); ok { if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap() dataMap := v.ColumnValueMap()
_, err := dao.db.Update( _, err := dao.NonconcurrentDB().Update(
m.TableName(), m.TableName(),
dataMap, dataMap,
dbx.HashExp{"id": m.GetId()}, dbx.HashExp{"id": m.GetId()},
@@ -187,7 +248,7 @@ func (dao *Dao) update(m models.Model) error {
return err return err
} }
} else { } else {
if err := dao.db.Model(m).Update(); err != nil { if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
return err return err
} }
} }
@@ -228,18 +289,18 @@ func (dao *Dao) create(m models.Model) error {
dataMap["id"] = m.GetId() dataMap["id"] = m.GetId()
} }
_, err := dao.db.Insert(m.TableName(), dataMap).Execute() _, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
if err != nil { if err != nil {
return err return err
} }
} else { } else {
if err := dao.db.Model(m).Insert(); err != nil { if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
return err return err
} }
} }
// clears the "new" model flag // clears the "new" model flag
m.UnmarkAsNew() m.MarkAsNotNew()
if dao.AfterCreateFunc != nil { if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(dao, m) dao.AfterCreateFunc(dao, m)
@@ -247,3 +308,20 @@ func (dao *Dao) create(m models.Model) error {
return nil return nil
} }
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
retryDao := dao
return baseLockRetry(func(attempt int) error {
if attempt == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
retryDao.AfterCreateFunc = dao.AfterCreateFunc
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
}
return op(retryDao)
}, dao.MaxLockRetries)
}
+57
View File
@@ -0,0 +1,57 @@
package daos
import (
"context"
"strings"
"time"
"github.com/pocketbase/dbx"
)
// default retries intervals (in ms)
var defaultRetryIntervals = []int{100, 250, 350, 500, 700, 1000}
func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
return func(q *dbx.Query, op func() error) error {
if q.Context() == nil {
cancelCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer func() {
cancel()
//nolint:staticcheck
q.WithContext(nil) // reset
}()
q.WithContext(cancelCtx)
}
return baseLockRetry(func(attempt int) error {
return op()
}, maxRetries)
}
}
func baseLockRetry(op func(attempt int) error, maxRetries int) error {
attempt := 1
Retry:
err := op(attempt)
if err != nil &&
attempt <= maxRetries &&
// we are checking the err message to handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(getDefaultRetryInterval(attempt))
attempt++
goto Retry
}
return err
}
func getDefaultRetryInterval(attempt int) time.Duration {
if attempt < 0 || attempt > len(defaultRetryIntervals)-1 {
return time.Duration(defaultRetryIntervals[len(defaultRetryIntervals)-1]) * time.Millisecond
}
return time.Duration(defaultRetryIntervals[attempt]) * time.Millisecond
}
+60
View File
@@ -0,0 +1,60 @@
package daos
import (
"errors"
"testing"
)
func TestGetDefaultRetryInterval(t *testing.T) {
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i)
}
if i := getDefaultRetryInterval(999); i.Milliseconds() != 1000 {
t.Fatalf("Expected 1000ms, got %v", i)
}
if i := getDefaultRetryInterval(3); i.Milliseconds() != 500 {
t.Fatalf("Expected 500ms, got %v", i)
}
}
func TestBaseLockRetry(t *testing.T) {
scenarios := []struct {
err error
failUntilAttempt int
expectedAttempts int
}{
{nil, 3, 1},
{errors.New("test"), 3, 1},
{errors.New("database is locked"), 3, 3},
}
for i, s := range scenarios {
lastAttempt := 0
err := baseLockRetry(func(attempt int) error {
lastAttempt = attempt
if attempt < s.failUntilAttempt {
return s.err
}
return nil
}, s.failUntilAttempt+2)
if lastAttempt != s.expectedAttempts {
t.Errorf("[%d] Expected lastAttempt to be %d, got %d", i, s.expectedAttempts, lastAttempt)
}
if s.failUntilAttempt == s.expectedAttempts && err != nil {
t.Errorf("[%d] Expected nil, got err %v", i, err)
continue
}
if s.failUntilAttempt != s.expectedAttempts && s.err != nil && err == nil {
t.Errorf("[%d] Expected error %q, got nil", i, s.err)
continue
}
}
}
+209 -7
View File
@@ -3,6 +3,7 @@ package daos_test
import ( import (
"errors" "errors"
"testing" "testing"
"time"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
@@ -20,6 +21,25 @@ func TestNew(t *testing.T) {
} }
} }
func TestNewMultiDB(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
if dao.DB() != testApp.Dao().ConcurrentDB() {
t.Fatal("[db-concurrentDB] The 2 db instances are different")
}
if dao.ConcurrentDB() != testApp.Dao().ConcurrentDB() {
t.Fatal("[concurrentDB-concurrentDB] The 2 db instances are different")
}
if dao.NonconcurrentDB() != testApp.Dao().NonconcurrentDB() {
t.Fatal("[nonconcurrentDB-nonconcurrentDB] The 2 db instances are different")
}
}
func TestDaoModelQuery(t *testing.T) { func TestDaoModelQuery(t *testing.T) {
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -35,8 +55,8 @@ func TestDaoModelQuery(t *testing.T) {
"SELECT {{_collections}}.* FROM `_collections`", "SELECT {{_collections}}.* FROM `_collections`",
}, },
{ {
&models.User{}, &models.Admin{},
"SELECT {{_users}}.* FROM `_users`", "SELECT {{_admins}}.* FROM `_admins`",
}, },
{ {
&models.Request{}, &models.Request{},
@@ -52,6 +72,24 @@ func TestDaoModelQuery(t *testing.T) {
} }
} }
func TestDaoModelQueryCancellation(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
dao := daos.New(testApp.DB())
m := &models.Admin{}
if err := dao.ModelQuery(m).One(m); err != nil {
t.Fatalf("Failed to execute control query: %v", err)
}
dao.ModelQueryTimeout = 0 * time.Millisecond
if err := dao.ModelQuery(m).One(m); err == nil {
t.Fatal("Expected to be cancelled, got nil")
}
}
func TestDaoFindById(t *testing.T) { func TestDaoFindById(t *testing.T) {
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -64,19 +102,19 @@ func TestDaoFindById(t *testing.T) {
// missing id // missing id
{ {
&models.Collection{}, &models.Collection{},
"00000000-075d-49fe-9d09-ea7e951000dc", "missing",
true, true,
}, },
// existing collection id // existing collection id
{ {
&models.Collection{}, &models.Collection{},
"3f2888f8-075d-49fe-9d09-ea7e951000dc", "wsmn24bux7wo113",
false, false,
}, },
// existing user id // existing admin id
{ {
&models.User{}, &models.Admin{},
"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", "sbmbsdb40jyxf7h",
false, false,
}, },
} }
@@ -289,6 +327,170 @@ 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) { func TestDaoBeforeHooksError(t *testing.T) {
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
+134 -48
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
) )
// CollectionQuery returns a new Collection select query. // CollectionQuery returns a new Collection select query.
@@ -15,15 +16,31 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Collection{}) 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) { func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
model := &models.Collection{} model := &models.Collection{}
err := dao.CollectionQuery(). err := dao.CollectionQuery().
AndWhere(dbx.Or( AndWhere(dbx.NewExp("[[id]] = {:id} OR LOWER([[name]])={:name}", dbx.Params{
dbx.HashExp{"id": nameOrId}, "id": nameOrId,
dbx.HashExp{"name": nameOrId}, "name": strings.ToLower(nameOrId),
)). })).
Limit(1). Limit(1).
One(model) One(model)
@@ -37,58 +54,47 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
// IsCollectionNameUnique checks that there is no existing collection // IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!). // with the provided name (case insensitive!).
// //
// Note: case sensitive check because the name is used also as a table name for the records. // Note: case insensitive check because the name is used also as a table name for the records.
func (dao *Dao) IsCollectionNameUnique(name string, excludeId string) bool { func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" { if name == "" {
return false return false
} }
var exists bool query := dao.CollectionQuery().
err := dao.CollectionQuery().
Select("count(*)"). Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})). AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1). Limit(1)
Row(&exists)
return err == nil && !exists if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
} query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
// FindCollectionsWithUserFields finds all collections that has var exists bool
// at least one user schema field.
func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
result := []*models.Collection{}
err := dao.CollectionQuery(). return query.Row(&exists) == nil && !exists
InnerJoin(
"json_each(schema) as jsonField",
dbx.NewExp(
"json_extract(jsonField.value, '$.type') = {:type}",
dbx.Params{"type": schema.FieldTypeUser},
),
).
All(&result)
return result, err
} }
// FindCollectionReferences returns information for all // FindCollectionReferences returns information for all
// relation schema fields referencing the provided collection. // relation schema fields referencing the provided collection.
// //
// If the provided collection has reference to itself then it will be // 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. // 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{} collections := []*models.Collection{}
err := dao.CollectionQuery(). query := dao.CollectionQuery()
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
All(&collections) if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
if err != nil { query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
if err := query.All(&collections); err != nil {
return nil, err return nil, err
} }
result := map[*models.Collection][]*schema.SchemaField{} result := map[*models.Collection][]*schema.SchemaField{}
for _, c := range collections { for _, c := range collections {
for _, f := range c.Schema.Fields() { for _, f := range c.Schema.Fields() {
if f.Type != schema.FieldTypeRelation { if f.Type != schema.FieldTypeRelation {
@@ -123,13 +129,23 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
return err return err
} }
if total := len(result); total > 0 { if total := len(result); total > 0 {
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total) names := make([]string, 0, len(result))
for ref := range result {
names = append(names, ref.Name)
}
return fmt.Errorf("The collection %q has external relation field references (%s).", collection.Name, strings.Join(names, ", "))
} }
return dao.RunInTransaction(func(txDao *Dao) error { return dao.RunInTransaction(func(txDao *Dao) error {
// delete the related records table // delete the related view or records table
if err := txDao.DeleteTable(collection.Name); err != nil { if collection.IsView() {
return err if err := txDao.DeleteView(collection.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(collection.Name); err != nil {
return err
}
} }
return txDao.Delete(collection) return txDao.Delete(collection)
@@ -152,13 +168,23 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
} }
return dao.RunInTransaction(func(txDao *Dao) error { return dao.RunInTransaction(func(txDao *Dao) error {
// persist the collection model // set default collection type
if err := txDao.Save(collection); err != nil { if collection.Type == "" {
return err collection.Type = models.CollectionTypeBase
} }
// sync the changes with the related records table switch collection.Type {
return txDao.SyncRecordTableSchema(collection, oldCollection) case models.CollectionTypeView:
return txDao.saveViewCollection(collection, oldCollection)
default:
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
}
// sync the changes with the related records table
return txDao.SyncRecordTableSchema(collection, oldCollection)
}
}) })
} }
@@ -196,7 +222,14 @@ func (dao *Dao) ImportCollections(
imported.RefreshId() imported.RefreshId()
} }
// set default type if missing
if imported.Type == "" {
imported.Type = models.CollectionTypeBase
}
if existing, ok := mappedExisting[imported.GetId()]; ok { if existing, ok := mappedExisting[imported.GetId()]; ok {
imported.MarkAsNotNew()
// preserve original created date // preserve original created date
if !existing.Created.IsZero() { if !existing.Created.IsZero() {
imported.Created = existing.Created imported.Created = existing.Created
@@ -256,8 +289,14 @@ func (dao *Dao) ImportCollections(
continue // exist continue // exist
} }
if err := txDao.DeleteTable(existing.Name); err != nil { if existing.IsView() {
return err if err := txDao.DeleteView(existing.Name); err != nil {
return err
}
} else {
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
} }
} }
} }
@@ -265,11 +304,58 @@ func (dao *Dao) ImportCollections(
// sync the upserted collections with the related records table // sync the upserted collections with the related records table
for _, imported := range importedCollections { for _, imported := range importedCollections {
existing := mappedExisting[imported.GetId()] existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err if imported.IsView() {
if err := txDao.saveViewCollection(imported, existing); err != nil {
return err
}
} else {
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
}
} }
} }
return nil return nil
}) })
} }
// saveViewCollection persists the provided View collection changes:
// - deletes the old related SQL view (if any)
// - creates a new SQL view with the latest newCollection.Options.Query
// - generates a new schema based on newCollection.Options.Query
// - updates newCollection.Schema based on the generated view table info and query
// - saves the newCollection
//
// This method returns an error if newCollection is not a "view".
func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollection *models.Collection) error {
if newCollection.IsAuth() {
return errors.New("not a view collection")
}
return dao.RunInTransaction(func(txDao *Dao) error {
query := newCollection.ViewOptions().Query
// generate collection schema from the query
schema, err := txDao.CreateViewSchema(query)
if err != nil {
return err
}
// delete old renamed view
if oldCollection != nil && newCollection.Name != oldCollection.Name {
if err := txDao.DeleteView(oldCollection.Name); err != nil {
return err
}
}
// (re)create the view
if err := txDao.SaveView(newCollection.Name, query); err != nil {
return err
}
newCollection.Schema = schema
return txDao.Save(newCollection)
})
}
+164 -140
View File
@@ -3,6 +3,7 @@ package daos_test
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"testing" "testing"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
@@ -24,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, 5},
}
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) { func TestFindCollectionByNameOrId(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -34,9 +70,9 @@ func TestFindCollectionByNameOrId(t *testing.T) {
}{ }{
{"", true}, {"", true},
{"missing", true}, {"missing", true},
{"00000000-075d-49fe-9d09-ea7e951000dc", true}, {"wsmn24bux7wo113", false},
{"3f2888f8-075d-49fe-9d09-ea7e951000dc", false}, {"demo1", false},
{"demo", false}, {"DEMO1", false}, // case insensitive check
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
@@ -44,11 +80,11 @@ func TestFindCollectionByNameOrId(t *testing.T) {
hasErr := err != nil hasErr := err != nil
if hasErr != scenario.expectError { if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
} }
if model != nil && model.Id != scenario.nameOrId && 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) t.Errorf("[%d] Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
} }
} }
} }
@@ -63,37 +99,16 @@ func TestIsCollectionNameUnique(t *testing.T) {
expected bool expected bool
}{ }{
{"", "", false}, {"", "", false},
{"demo", "", false}, {"demo1", "", false},
{"Demo1", "", false},
{"new", "", true}, {"new", "", true},
{"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true}, {"demo1", "wsmn24bux7wo113", true},
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId) result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId)
if result != scenario.expected { if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
}
}
}
func 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)
} }
} }
} }
@@ -102,12 +117,18 @@ func TestFindCollectionReferences(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo") collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := app.Dao().FindCollectionReferences(collection, collection.Id) result, err := app.Dao().FindCollectionReferences(
collection,
collection.Id,
// test whether "nonempty" exclude ids condition will be skipped
"",
"",
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -116,18 +137,25 @@ func TestFindCollectionReferences(t *testing.T) {
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result) t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
} }
expectedFields := []string{"onerel", "manyrels", "cascaderel"} 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 { for col, fields := range result {
if col.Name != "demo2" { if col.Name != "demo4" {
t.Fatalf("Expected collection demo2, got %s", col.Name) t.Fatalf("Expected collection demo4, got %s", col.Name)
} }
if len(fields) != len(expectedFields) { if len(fields) != len(expectedFields) {
t.Fatalf("Expected fields %v, got %v", expectedFields, fields) t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
} }
for i, f := range fields { for i, f := range fields {
if !list.ExistInSlice(f.Name, expectedFields) { if !list.ExistInSlice(f.Name, expectedFields) {
t.Fatalf("(%d) Didn't expect field %v", i, f) t.Fatalf("[%d] Didn't expect field %v", i, f)
} }
} }
} }
@@ -137,16 +165,28 @@ func TestDeleteCollection(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
c0 := &models.Collection{} colEmpty := &models.Collection{}
c1, err := app.Dao().FindCollectionByNameOrId("demo")
colAuth, err := app.Dao().FindCollectionByNameOrId("clients")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
c2, err := app.Dao().FindCollectionByNameOrId("demo2")
colReferenced, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
colSystem, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
colSystem.System = true
if err := app.Dao().Save(colSystem); err != nil {
t.Fatal(err)
}
colView, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -155,18 +195,28 @@ func TestDeleteCollection(t *testing.T) {
model *models.Collection model *models.Collection
expectError bool expectError bool
}{ }{
{c0, true}, {colEmpty, true},
{c1, true}, // is part of a reference {colAuth, false},
{c2, false}, {colReferenced, true},
{c3, true}, // system {colSystem, true},
{colView, false},
} }
for i, scenario := range scenarios { for i, s := range scenarios {
err := app.Dao().DeleteCollection(scenario.model) err := app.Dao().DeleteCollection(s.model)
hasErr := err != nil
if hasErr != scenario.expectError { hasErr := err != nil
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr) if hasErr != s.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if app.Dao().HasTable(s.model.Name) {
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
} }
} }
} }
@@ -177,6 +227,7 @@ func TestSaveCollectionCreate(t *testing.T) {
collection := &models.Collection{ collection := &models.Collection{
Name: "new_test", Name: "new_test",
Type: models.CollectionTypeBase,
Schema: schema.NewSchema( Schema: schema.NewSchema(
&schema.SchemaField{ &schema.SchemaField{
Type: schema.FieldTypeText, Type: schema.FieldTypeText,
@@ -211,7 +262,7 @@ func TestSaveCollectionCreate(t *testing.T) {
} }
for i, c := range columns { for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) { if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c) t.Fatalf("[%d] Didn't expect record column %s", i, c)
} }
} }
} }
@@ -239,7 +290,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
} }
// check if the records table has the schema fields // 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) columns, err := app.Dao().GetTableColumns(collection.Name)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -249,12 +300,14 @@ func TestSaveCollectionUpdate(t *testing.T) {
} }
for i, c := range columns { for i, c := range columns {
if !list.ExistInSlice(c, expectedColumns) { if !list.ExistInSlice(c, expectedColumns) {
t.Fatalf("(%d) Didn't expect record column %s", i, c) t.Fatalf("[%d] Didn't expect record column %s", i, c)
} }
} }
} }
func TestImportCollections(t *testing.T) { func TestImportCollections(t *testing.T) {
totalCollections := 10
scenarios := []struct { scenarios := []struct {
name string name string
jsonData string jsonData string
@@ -262,31 +315,24 @@ func TestImportCollections(t *testing.T) {
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
expectError bool expectError bool
expectCollectionsCount int expectCollectionsCount int
beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection) afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
}{ }{
{ {
name: "empty collections", name: "empty collections",
jsonData: `[]`, jsonData: `[]`,
expectError: true, expectError: true,
expectCollectionsCount: 5, expectCollectionsCount: totalCollections,
},
{
name: "check db constraints",
jsonData: `[
{"name": "import_test", "schema": []}
]`,
deleteMissing: false,
expectError: true,
expectCollectionsCount: 5,
}, },
{ {
name: "minimal collection import", name: "minimal collection import",
jsonData: `[ jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]} {"name": "import_test1", "schema": [{"name":"test", "type": "text"}]},
{"name": "import_test2", "type": "auth"}
]`, ]`,
deleteMissing: false, deleteMissing: false,
expectError: false, expectError: false,
expectCollectionsCount: 6, expectCollectionsCount: totalCollections + 2,
}, },
{ {
name: "minimal collection import + failed beforeRecordsSync", name: "minimal collection import + failed beforeRecordsSync",
@@ -298,7 +344,7 @@ func TestImportCollections(t *testing.T) {
}, },
deleteMissing: false, deleteMissing: false,
expectError: true, expectError: true,
expectCollectionsCount: 5, expectCollectionsCount: totalCollections,
}, },
{ {
name: "minimal collection import + successful beforeRecordsSync", name: "minimal collection import + successful beforeRecordsSync",
@@ -310,13 +356,13 @@ func TestImportCollections(t *testing.T) {
}, },
deleteMissing: false, deleteMissing: false,
expectError: false, expectError: false,
expectCollectionsCount: 6, expectCollectionsCount: totalCollections + 1,
}, },
{ {
name: "new + update + delete system collection", name: "new + update + delete system collection",
jsonData: `[ jsonData: `[
{ {
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc", "id":"wsmn24bux7wo113",
"name":"demo", "name":"demo",
"schema":[ "schema":[
{ {
@@ -346,50 +392,49 @@ func TestImportCollections(t *testing.T) {
]`, ]`,
deleteMissing: true, deleteMissing: true,
expectError: true, expectError: true,
expectCollectionsCount: 5, expectCollectionsCount: totalCollections,
}, },
{ {
name: "new + update + delete non-system collection", name: "new + update + delete non-system collection",
jsonData: `[ jsonData: `[
{ {
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b", "id": "kpv709sk2lqbqk8",
"name":"profiles", "system": true,
"system":true, "name": "nologin",
"listRule":"userId = @request.user.id", "type": "auth",
"viewRule":"created > 'test_change'", "options": {
"createRule":"userId = @request.user.id", "allowEmailAuth": false,
"updateRule":"userId = @request.user.id", "allowOAuth2Auth": false,
"deleteRule":"userId = @request.user.id", "allowUsernameAuth": false,
"schema":[ "exceptEmailDomains": [],
"manageRule": "@request.auth.collectionName = 'users'",
"minPasswordLength": 8,
"onlyEmailDomains": [],
"requireEmail": true
},
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"schema": [
{ {
"id":"koih1lqx", "id": "x8zzktwe",
"name":"userId", "name": "name",
"type":"user", "type": "text",
"system":true, "system": false,
"required":true, "required": false,
"unique":true, "unique": false,
"options":{ "options": {
"maxSelect":1, "min": null,
"cascadeDelete":true "max": null,
} "pattern": ""
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
} }
} }
] ]
}, },
{ {
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc", "id":"wsmn24bux7wo113",
"name":"demo", "name":"demo",
"schema":[ "schema":[
{ {
@@ -417,48 +462,26 @@ func TestImportCollections(t *testing.T) {
"type":"bool" "type":"bool"
} }
] ]
},
{
"id": "test_new_view",
"name": "new_view",
"type": "view",
"options": {
"query": "select id from demo2"
}
} }
]`, ]`,
deleteMissing: true, deleteMissing: true,
expectError: false, expectError: false,
expectCollectionsCount: 3, expectCollectionsCount: 4,
}, },
{ {
name: "test with deleteMissing: false", name: "test with deleteMissing: false",
jsonData: `[ jsonData: `[
{ {
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b", "id":"wsmn24bux7wo113",
"name":"profiles", "name":"demo1",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":true
}
},
{
"id":"abcd_import",
"name":"new_field",
"type":"bool"
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[ "schema":[
{ {
"id":"_2hlxbmp", "id":"_2hlxbmp",
@@ -506,14 +529,15 @@ func TestImportCollections(t *testing.T) {
]`, ]`,
deleteMissing: false, deleteMissing: false,
expectError: false, expectError: false,
expectCollectionsCount: 6, expectCollectionsCount: totalCollections + 1,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) { afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{ expectedCollectionFields := map[string]int{
"profiles": 6, "nologin": 1,
"demo": 3, "demo1": 15,
"demo2": 14, "demo2": 2,
"demo3": 1, "demo3": 2,
"demo4": 6, "demo4": 11,
"demo5": 6,
"new_import": 1, "new_import": 1,
} }
for name, expectedCount := range expectedCollectionFields { for name, expectedCount := range expectedCollectionFields {
+14 -31
View File
@@ -12,13 +12,16 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.ExternalAuth{}) return dao.ModelQuery(&models.ExternalAuth{})
} }
/// FindAllExternalAuthsByUserId returns all ExternalAuth models // FindAllExternalAuthsByRecord returns all ExternalAuth models
/// linked to the provided userId. // linked to the provided auth record.
func (dao *Dao) FindAllExternalAuthsByUserId(userId string) ([]*models.ExternalAuth, error) { func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
auths := []*models.ExternalAuth{} auths := []*models.ExternalAuth{}
err := dao.ExternalAuthQuery(). err := dao.ExternalAuthQuery().
AndWhere(dbx.HashExp{"userId": userId}). AndWhere(dbx.HashExp{
"collectionId": authRecord.Collection().Id,
"recordId": authRecord.Id,
}).
OrderBy("created ASC"). OrderBy("created ASC").
All(&auths) All(&auths)
@@ -50,15 +53,16 @@ func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models
return model, nil return model, nil
} }
// FindExternalAuthByUserIdAndProvider returns the first available // FindExternalAuthByRecordAndProvider returns the first available
// ExternalAuth model for the specified userId and provider. // ExternalAuth model for the specified record data and provider.
func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*models.ExternalAuth, error) { func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
model := &models.ExternalAuth{} model := &models.ExternalAuth{}
err := dao.ExternalAuthQuery(). err := dao.ExternalAuthQuery().
AndWhere(dbx.HashExp{ AndWhere(dbx.HashExp{
"userId": userId, "collectionId": authRecord.Collection().Id,
"provider": provider, "recordId": authRecord.Id,
"provider": provider,
}). }).
Limit(1). Limit(1).
One(model) One(model)
@@ -74,7 +78,7 @@ func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*m
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error { func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
// extra check the model data in case the provider's API response // extra check the model data in case the provider's API response
// has changed and no longer returns the expected fields // has changed and no longer returns the expected fields
if model.UserId == "" || model.Provider == "" || model.ProviderId == "" { if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
return errors.New("Missing required ExternalAuth fields.") return errors.New("Missing required ExternalAuth fields.")
} }
@@ -82,27 +86,6 @@ func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
} }
// DeleteExternalAuth deletes the provided ExternalAuth model. // DeleteExternalAuth deletes the provided ExternalAuth model.
//
// The delete may fail if the linked user doesn't have an email and
// there are no other linked ExternalAuth models available.
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error { func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
user, err := dao.FindUserById(model.UserId)
if err != nil {
return err
}
// if the user doesn't have an email, make sure that there
// is at least one other external auth relation available
if user.Email == "" {
allExternalAuths, err := dao.FindAllExternalAuthsByUserId(user.Id)
if err != nil {
return err
}
if len(allExternalAuths) <= 1 {
return errors.New("You cannot delete the only available external auth relation because the user doesn't have an email address.")
}
}
return dao.Delete(model) return dao.Delete(model)
} }
+38 -44
View File
@@ -19,7 +19,7 @@ func TestExternalAuthQuery(t *testing.T) {
} }
} }
func TestFindAllExternalAuthsByUserId(t *testing.T) { func TestFindAllExternalAuthsByRecord(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -27,16 +27,20 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
userId string userId string
expectedCount int expectedCount int
}{ }{
{"", 0}, {"oap640cot4yru2s", 0},
{"missing", 0}, {"4q1xlclmfloku33", 2},
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", 0},
{"cx9u0dh2udo8xol", 2},
} }
for i, s := range scenarios { for i, s := range scenarios {
auths, err := app.Dao().FindAllExternalAuthsByUserId(s.userId) record, err := app.Dao().FindRecordById("users", s.userId)
if err != nil { if err != nil {
t.Errorf("(%d) Unexpected error %v", i, err) 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 continue
} }
@@ -45,8 +49,8 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
} }
for _, auth := range auths { for _, auth := range auths {
if auth.UserId != s.userId { if auth.RecordId != record.Id {
t.Errorf("(%d) Expected all auths to be linked to userId %s, got %v", i, s.userId, auth) t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
} }
} }
} }
@@ -65,8 +69,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
{"github", "", ""}, {"github", "", ""},
{"github", "id1", ""}, {"github", "id1", ""},
{"github", "id2", ""}, {"github", "id2", ""},
{"google", "id1", "abcdefghijklmn0"}, {"google", "test123", "clmflokuq1xl341"},
{"gitlab", "id2", "abcdefghijklmn1"}, {"gitlab", "test123", "dlmflokuq1xl342"},
} }
for i, s := range scenarios { for i, s := range scenarios {
@@ -85,7 +89,7 @@ func TestFindExternalAuthByProvider(t *testing.T) {
} }
} }
func TestFindExternalAuthByUserIdAndProvider(t *testing.T) { func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -94,17 +98,19 @@ func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
provider string provider string
expectedId string expectedId string
}{ }{
{"", "", ""}, {"bgs820n361vj1qd", "google", ""},
{"", "github", ""}, {"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
{"123456", "github", ""}, // missing user and provider record {"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
{"123456", "google", ""}, // missing user but existing provider record
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", "google", ""},
{"cx9u0dh2udo8xol", "google", "abcdefghijklmn0"},
{"cx9u0dh2udo8xol", "gitlab", "abcdefghijklmn1"},
} }
for i, s := range scenarios { for i, s := range scenarios {
auth, err := app.Dao().FindExternalAuthByUserIdAndProvider(s.userId, s.provider) 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 hasErr := err != nil
expectErr := s.expectedId == "" expectErr := s.expectedId == ""
@@ -130,9 +136,10 @@ func TestSaveExternalAuth(t *testing.T) {
} }
auth := &models.ExternalAuth{ auth := &models.ExternalAuth{
UserId: "97cc3d3d-6ba2-383f-b42a-7bc84d27410c", RecordId: "o1y0dd0spd786md",
Provider: "test", CollectionId: "v851q4r790rhknl",
ProviderId: "test_id", Provider: "test",
ProviderId: "test_id",
} }
if err := app.Dao().SaveExternalAuth(auth); err != nil { if err := app.Dao().SaveExternalAuth(auth); err != nil {
@@ -154,42 +161,29 @@ func TestDeleteExternalAuth(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
user, err := app.Dao().FindUserById("cx9u0dh2udo8xol") record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
auths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id) auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := app.Dao().DeleteExternalAuth(auths[0]); err != nil { for _, auth := range auths {
t.Fatalf("Failed to delete the first ExternalAuth relation, got \n%v", err) if err := app.Dao().DeleteExternalAuth(auth); err != nil {
} t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
}
if err := app.Dao().DeleteExternalAuth(auths[1]); err == nil {
t.Fatal("Expected delete to fail, got nil")
}
// update the user model and try again
user.Email = "test_new@example.com"
if err := app.Dao().SaveUser(user); err != nil {
t.Fatal(err)
}
// try to delete auths[1] again
if err := app.Dao().DeleteExternalAuth(auths[1]); err != nil {
t.Fatalf("Failed to delete the last ExternalAuth relation, got \n%v", err)
} }
// check if the relations were really deleted // check if the relations were really deleted
newAuths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id) newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(newAuths) != 0 { if len(newAuths) != 0 {
t.Fatalf("Expected all user %s ExternalAuth relations to be deleted, got \n%v", user.Id, newAuths) t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
} }
} }
+431 -244
View File
@@ -8,9 +8,11 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
) )
// RecordQuery returns a new Record select query. // RecordQuery returns a new Record select query.
@@ -18,360 +20,545 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
tableName := collection.Name tableName := collection.Name
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName)) selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
return dao.DB().Select(selectCols).From(tableName) return dao.DB().
Select(selectCols).
From(tableName).
WithBuildHook(func(query *dbx.Query) {
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
switch v := a.(type) {
case *models.Record:
if v == nil {
return op(a)
}
row := dbx.NullStringMap{}
if err := op(&row); err != nil {
return err
}
record := models.NewRecordFromNullStringMap(collection, row)
*v = *record
return nil
default:
return op(a)
}
}).
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
switch v := sliceA.(type) {
case *[]*models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
*v = records
return nil
case *[]models.Record:
if v == nil {
return op(sliceA)
}
rows := []dbx.NullStringMap{}
if err := op(&rows); err != nil {
return err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
nonPointers := make([]models.Record, len(records))
for i, r := range records {
nonPointers[i] = *r
}
*v = nonPointers
return nil
default:
return op(sliceA)
}
})
})
} }
// FindRecordById finds the Record model by its id. // FindRecordById finds the Record model by its id.
func (dao *Dao) FindRecordById( func (dao *Dao) FindRecordById(
collection *models.Collection, collectionNameOrId string,
recordId string, recordId string,
filter func(q *dbx.SelectQuery) error, optFilters ...func(q *dbx.SelectQuery) error,
) (*models.Record, error) { ) (*models.Record, error) {
tableName := collection.Name collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
query := dao.RecordQuery(collection). query := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{tableName + ".id": recordId}) AndWhere(dbx.HashExp{collection.Name + ".id": recordId})
if filter != nil { for _, filter := range optFilters {
if filter == nil {
continue
}
if err := filter(query); err != nil { if err := filter(query); err != nil {
return nil, err return nil, err
} }
} }
row := dbx.NullStringMap{} record := &models.Record{}
if err := query.Limit(1).One(row); err != nil {
if err := query.Limit(1).One(record); err != nil {
return nil, err return nil, err
} }
return models.NewRecordFromNullStringMap(collection, row), nil return record, nil
} }
// FindRecordsByIds finds all Record models by the provided ids. // FindRecordsByIds finds all Record models by the provided ids.
// If no records are found, returns an empty slice. // If no records are found, returns an empty slice.
func (dao *Dao) FindRecordsByIds( func (dao *Dao) FindRecordsByIds(
collection *models.Collection, collectionNameOrId string,
recordIds []string, recordIds []string,
filter func(q *dbx.SelectQuery) error, optFilters ...func(q *dbx.SelectQuery) error,
) ([]*models.Record, error) { ) ([]*models.Record, error) {
tableName := collection.Name collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
query := dao.RecordQuery(collection). 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 { if err := filter(query); err != nil {
return nil, err return nil, err
} }
} }
rows := []dbx.NullStringMap{} records := make([]*models.Record, 0, len(recordIds))
if err := query.All(&rows); err != nil {
if err := query.All(&records); err != nil {
return nil, err return nil, err
} }
return models.NewRecordsFromNullStringMaps(collection, rows), nil return records, nil
} }
// FindRecordsByExpr finds all records by the provided db expression. // FindRecordsByExpr finds all records by the specified db expression.
// If no records are found, returns an empty slice. //
// Returns all collection records if no expressions are provided.
//
// Returns an empty slice if no records are found.
// //
// Example: // Example:
// expr := dbx.HashExp{"email": "test@example.com"} //
// dao.FindRecordsByExpr(collection, expr) // expr1 := dbx.HashExp{"email": "test@example.com"}
func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expression) ([]*models.Record, error) { // expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
if expr == nil { // dao.FindRecordsByExpr("example", expr1, expr2)
return nil, errors.New("Missing filter expression") func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
} collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
rows := []dbx.NullStringMap{}
err := dao.RecordQuery(collection).
AndWhere(expr).
All(&rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return models.NewRecordsFromNullStringMaps(collection, rows), nil query := dao.RecordQuery(collection)
// add only the non-nil expressions
for _, expr := range exprs {
if expr != nil {
query.AndWhere(expr)
}
}
var records []*models.Record
if err := query.All(&records); err != nil {
return nil, err
}
return records, nil
} }
// FindFirstRecordByData returns the first found record matching // FindFirstRecordByData returns the first found record matching
// the provided key-value pair. // the provided key-value pair.
func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string, value any) (*models.Record, error) { func (dao *Dao) FindFirstRecordByData(
row := dbx.NullStringMap{} collectionNameOrId string,
key string,
err := dao.RecordQuery(collection). value any,
AndWhere(dbx.HashExp{key: value}). ) (*models.Record, error) {
Limit(1). collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
One(row)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return models.NewRecordFromNullStringMap(collection, row), nil record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(record)
if err != nil {
return nil, err
}
return record, nil
} }
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value. // 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 // NB! Array values (eg. from multiple select fields) are matched
// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness // as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
// depends on the elements order. Or in other words the following values // depends on the elements order. Or in other words the following values
// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}` // are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
func (dao *Dao) IsRecordValueUnique( func (dao *Dao) IsRecordValueUnique(
collection *models.Collection, collectionNameOrId string,
key string, key string,
value any, value any,
excludeId string, excludeIds ...string,
) bool { ) bool {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return false
}
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
}
expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
}
query := dao.RecordQuery(collection).
Select("count(*)").
AndWhere(expr).
Limit(1)
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool var exists bool
var normalizedVal any return query.Row(&exists) == nil && !exists
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
default:
normalizedVal = val
}
err := dao.RecordQuery(collection).
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.HashExp{key: normalizedVal}).
Limit(1).
Row(&exists)
return err == nil && !exists
} }
// FindUserRelatedRecords returns all records that has a reference // FindAuthRecordByToken finds the auth record associated with the provided JWT token.
// to the provided User model (via the user shema field). //
func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) { // Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
if user.Id == "" { func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
return []*models.Record{}, nil unverifiedClaims, err := security.ParseUnverifiedJWT(token)
}
collections, err := dao.FindCollectionsWithUserFields()
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := []*models.Record{} // check required claims
for _, collection := range collections { id, _ := unverifiedClaims["id"].(string)
userFields := []*schema.SchemaField{} collectionId, _ := unverifiedClaims["collectionId"].(string)
if id == "" || collectionId == "" {
// prepare fields options return nil, errors.New("missing or invalid token claims")
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...)
} }
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 {
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
}
if !collection.IsAuth() {
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
Limit(1).
One(record)
if err != nil {
return nil, err
}
return record, 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 {
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
}
if !collection.IsAuth() {
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
}
record := &models.Record{}
err = dao.RecordQuery(collection).
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(username),
})).
Limit(1).
One(record)
if err != nil {
return nil, err
}
return record, 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. // SaveRecord upserts the provided Record model.
func (dao *Dao) SaveRecord(record *models.Record) error { 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) return dao.Save(record)
} }
// DeleteRecord deletes the provided Record model. // DeleteRecord deletes the provided Record model.
// //
// This method will also cascade the delete operation to all linked // This method will also cascade the delete operation to all linked
// relational records (delete or set to NULL, depending on the rel settings). // relational records (delete or unset, depending on the rel settings).
// //
// The delete operation may fail if the record is part of a required // 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). // reference in another record (aka. cannot be deleted or unset).
func (dao *Dao) DeleteRecord(record *models.Record) error { func (dao *Dao) DeleteRecord(record *models.Record) error {
// check for references // fetch rel references (if any)
// 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(), "") // 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 { if err != nil {
return err 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)
// -----------------------------------------------------------
return dao.RunInTransaction(func(txDao *Dao) error { return dao.RunInTransaction(func(txDao *Dao) error {
for refCollection, fields := range refs { // manually trigger delete on any linked external auth to ensure
for _, field := range fields { // that the `OnModel*` hooks are triggered
options, _ := field.Options.(*schema.RelationOptions) if record.Collection().IsAuth() {
// note: the select is outside of the transaction to minimize
rows := []dbx.NullStringMap{} // SQLITE_BUSY errors when mixing read&write in a single transaction
externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
// note: the select is not using the transaction dao to prevent SQLITE_LOCKED error when mixing read&write in a single transaction if err != nil {
err := dao.RecordQuery(refCollection). return err
AndWhere(dbx.Not(dbx.HashExp{"id": record.Id})). }
AndWhere(dbx.Like(field.Name, record.Id).Match(true, true)). for _, auth := range externalAuths {
All(&rows) if err := txDao.DeleteExternalAuth(auth); err != nil {
if err != nil {
return err return err
} }
}
}
// delete the record before the relation references 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
}
return txDao.cascadeRecordDelete(record, refs)
})
}
// cascadeRecordDelete triggers cascade deletion for the provided references.
//
// NB! This method is expected to be called inside a transaction.
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
for refCollection, fields := range refs {
if refCollection.IsView() {
continue // skip view collections
}
for _, field := range fields {
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
// @todo optimize single relation lookup
query := dao.RecordQuery(refCollection).
Distinct(true).
InnerJoin(fmt.Sprintf(
// note: the case is used to normalize the value access
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
if refCollection.Id == mainRecord.Collection().Id {
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
}
// trigger cascade for each batchSize rel items until there is none
batchSize := 4000
rows := make([]dbx.NullStringMap, 0, batchSize)
for {
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
return err
}
total := len(rows)
if total == 0 {
break
}
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows) refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
for _, refRecord := range refRecords {
ids := refRecord.GetStringSliceDataValue(field.Name)
// unset the record id err := dao.deleteRefRecords(mainRecord, refRecords, field)
for i := len(ids) - 1; i >= 0; i-- { if err != nil {
if ids[i] == record.Id { return err
ids = append(ids[:i], ids[i+1:]...)
break
}
}
// cascade delete the reference
// (only if there are no other active references in case of multiple select)
if options.CascadeDelete && len(ids) == 0 {
if err := txDao.DeleteRecord(refRecord); err != nil {
return err
}
// no further action are needed (the reference is deleted)
continue
}
if field.Required && len(ids) == 0 {
return fmt.Errorf("The record cannot be deleted because it is part of a required reference in record %s (%s collection).", refRecord.Id, refCollection.Name)
}
// save the reference changes
refRecord.SetDataValue(field.Name, field.PrepareValue(ids))
if err := txDao.SaveRecord(refRecord); err != nil {
return err
}
} }
if total < batchSize {
break // no more items
}
rows = rows[:0] // keep allocated memory
} }
} }
return txDao.Delete(record)
})
}
// SyncRecordTableSchema compares the two provided collections
// and applies the necessary related record table changes.
//
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
// create
if oldCollection == nil {
cols := map[string]string{
schema.ReservedFieldNameId: "TEXT PRIMARY KEY",
schema.ReservedFieldNameCreated: `TEXT DEFAULT "" NOT NULL`,
schema.ReservedFieldNameUpdated: `TEXT DEFAULT "" NOT NULL`,
}
tableName := newCollection.Name
// add schema field definitions
for _, field := range newCollection.Schema.Fields() {
cols[field.Name] = field.ColDefinition()
}
// create table
_, tableErr := dao.DB().CreateTable(tableName, cols).Execute()
if tableErr != nil {
return tableErr
}
// add index on the base `created` column
_, indexErr := dao.DB().CreateIndex(tableName, tableName+"_created_idx", "created").Execute()
if indexErr != nil {
return indexErr
}
return nil
} }
// update return nil
return dao.RunInTransaction(func(txDao *Dao) error { }
oldTableName := oldCollection.Name
newTableName := newCollection.Name // deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set)
oldSchema := oldCollection.Schema // OR
newSchema := newCollection.Schema // just unset the record id from any relation field values (if they are not required).
//
// check for renamed table // NB! This method is expected to be called inside a transaction.
if !strings.EqualFold(oldTableName, newTableName) { func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute() options, _ := field.Options.(*schema.RelationOptions)
if err != nil { if options == nil {
return err return errors.New("relation field options are not initialized")
}
for _, refRecord := range refRecords {
ids := refRecord.GetStringSlice(field.Name)
// unset the record id
for i := len(ids) - 1; i >= 0; i-- {
if ids[i] == mainRecord.Id {
ids = append(ids[:i], ids[i+1:]...)
break
} }
} }
// check for deleted columns // cascade delete the reference
for _, oldField := range oldSchema.Fields() { // (only if there are no other active references in case of multiple select)
if f := newSchema.GetFieldById(oldField.Id); f != nil { if options.CascadeDelete && len(ids) == 0 {
continue // exist if err := dao.DeleteRecord(refRecord); err != nil {
}
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
if err != nil {
return err return err
} }
// no further actions are needed (the reference is deleted)
continue
} }
// check for new or renamed columns
toRename := map[string]string{}
for _, field := range newSchema.Fields() {
oldField := oldSchema.GetFieldById(field.Id)
// 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 { if field.Required && len(ids) == 0 {
tempName := field.Name + security.RandomString(5) return fmt.Errorf("the record cannot be deleted because it is part of a required reference in record %s (%s collection)", refRecord.Id, refRecord.Collection().Name)
toRename[tempName] = field.Name
// add
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
if err != nil {
return err
}
} else if oldField.Name != field.Name {
tempName := field.Name + security.RandomString(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 // save the reference changes
for tempName, actualName := range toRename { refRecord.Set(field.Name, field.PrepareValue(ids))
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute() if err := dao.SaveRecord(refRecord); err != nil {
if err != nil { return err
return err
}
} }
}
return nil return nil
})
} }
+130 -21
View File
@@ -3,11 +3,16 @@ package daos
import ( import (
"errors" "errors"
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list" "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. // MaxExpandDepth specifies the max allowed nested expand depth path.
@@ -40,10 +45,13 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
return failed return failed
} }
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
// notes: // notes:
// - fetchFunc must be non-nil func // - fetchFunc must be non-nil func
// - all records are expected to be from the same collection // - all records are expected to be from the same collection
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path // - 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 { func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
if fetchFunc == nil { if fetchFunc == nil {
return errors.New("Relation records fetchFunc is not set.") return errors.New("Relation records fetchFunc is not set.")
@@ -53,29 +61,106 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
return nil return nil
} }
parts := strings.SplitN(expandPath, ".", 2)
// extract the relation field (if exist)
mainCollection := records[0].Collection() mainCollection := records[0].Collection()
relField := mainCollection.Schema.GetFieldByName(parts[0])
if relField == nil || relField.Type != schema.FieldTypeRelation { var relField *schema.SchemaField
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name) var relFieldOptions *schema.RelationOptions
} var relCollection *models.Collection
relField.InitOptions()
relFieldOptions, ok := relField.Options.(*schema.RelationOptions) parts := strings.SplitN(expandPath, ".", 2)
if !ok { matches := indirectExpandRegex.FindStringSubmatch(parts[0])
return fmt.Errorf("Cannot initialize the options of relation field %q.", 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
}
// @todo after the index optimizations consider allowing
// indirect expand for multi-relation fields
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)
}
} }
relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId) // ---------------------------------------------------------------
if err != nil {
return fmt.Errorf("Couldn't find collection %q.", relFieldOptions.CollectionId)
}
// extract the id of the relations to expand // extract the id of the relations to expand
relIds := make([]string, 0, len(records)) relIds := make([]string, 0, len(records))
for _, record := range records { for _, record := range records {
relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...) relIds = append(relIds, record.GetStringSlice(relField.Name)...)
} }
// fetch rels // fetch rels
@@ -93,13 +178,13 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
} }
// reindex with the rel id // reindex with the rel id
indexedRels := map[string]*models.Record{} indexedRels := make(map[string]*models.Record, len(rels))
for _, rel := range rels { for _, rel := range rels {
indexedRels[rel.GetId()] = rel indexedRels[rel.GetId()] = rel
} }
for _, model := range records { for _, model := range records {
relIds := model.GetStringSliceDataValue(relField.Name) relIds := model.GetStringSlice(relField.Name)
validRels := make([]*models.Record, 0, len(relIds)) validRels := make([]*models.Record, 0, len(relIds))
for _, id := range relIds { for _, id := range relIds {
@@ -112,14 +197,38 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
continue // no valid relations continue // no valid relations
} }
expandData := model.GetExpand() expandData := model.Expand()
// normalize and set the expanded relations // normalize access to the previously expanded rel records (if any)
if relFieldOptions.MaxSelect == 1 { 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
}
rel.MergeExpand(oldExpandedRel.Expand())
}
}
// update the expanded data
if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
expandData[relField.Name] = validRels[0] expandData[relField.Name] = validRels[0]
} else { } else {
expandData[relField.Name] = validRels expandData[relField.Name] = validRels
} }
model.SetExpand(expandData) model.SetExpand(expandData)
} }
+187 -89
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
) )
@@ -16,140 +17,189 @@ func TestExpandRecords(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct { scenarios := []struct {
testName string
collectionIdOrName string
recordIds []string recordIds []string
expands []string expands []string
fetchFunc daos.ExpandFetchFunc fetchFunc daos.ExpandFetchFunc
expectExpandProps int expectExpandProps int
expectExpandFailures int expectExpandFailures int
}{ }{
// empty records
{ {
"empty records",
"",
[]string{}, []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) { 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, 0,
0, 0,
}, },
// empty expand
{ {
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, "empty expand",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{}, []string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 0,
0, 0,
}, },
// empty fetchFunc
{ {
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, "empty fetchFunc",
[]string{"onerel", "manyrels.onerel.manyrels"}, "demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil, nil,
0, 0,
2, 2,
}, },
// fetchFunc with error
{ {
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, "fetchFunc with error",
[]string{"onerel", "manyrels.onerel.manyrels"}, "demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error") return nil, errors.New("test error")
}, },
0, 0,
2, 2,
}, },
// missing relation field
{ {
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, "missing relation field",
[]string{"invalid"}, "demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 0,
1, 1,
}, },
// existing, but non-relation type field
{ {
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, "existing, but non-relation type field",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"title"}, []string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 0,
1, 1,
}, },
// invalid/missing second level expand
{ {
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, "invalid/missing second level expand",
[]string{"manyrels.invalid"}, "demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 0,
1, 1,
}, },
// expand normalizations
{ {
"expand normalizations",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{ []string{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
"df55c8ff-45ef-4c82-8aed-6e2183fe1125", "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
"b84cd893-7119-43c9-8505-3c4e22da28a9", "self_rel_many", "self_rel_many.",
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", " self_rel_many ", "",
}, },
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel", "onerel", "onerel.", " onerel ", ""},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 9,
0, 0,
}, },
// single expand
{ {
"single expand",
"users",
[]string{ []string{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "bgs820n361vj1qd",
"df55c8ff-45ef-4c82-8aed-6e2183fe1125", "4q1xlclmfloku33",
"b84cd893-7119-43c9-8505-3c4e22da28a9", // no manyrels "oap640cot4yru2s", // no rels
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", // no manyrels
}, },
[]string{"manyrels"}, []string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 2,
0, 0,
}, },
// maxExpandDepth reached
{ {
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"}, "maxExpandDepth reached",
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"}, "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) { 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, 6,
0, 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,
},
{
"expand multiple relations sharing a common path",
"demo4",
[]string{"qzaqccwrmva4o1n"},
[]string{
"rel_one_no_cascade",
"rel_many_no_cascade",
"self_rel_many.self_rel_one.rel_many_cascade",
"self_rel_many.self_rel_one.rel_many_no_cascade_required",
},
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) ids := list.ToUniqueStringSlice(s.recordIds)
records, _ := app.Dao().FindRecordsByIds(col, ids, nil) records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc) failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
if len(failed) != s.expectExpandFailures { if len(failed) != s.expectExpandFailures {
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed) t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
} }
encoded, _ := json.Marshal(records) encoded, _ := json.Marshal(records)
encodedStr := string(encoded) encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, "@expand") totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps { if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr) t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
} }
} }
} }
@@ -158,109 +208,157 @@ func TestExpandRecord(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct { scenarios := []struct {
testName string
collectionIdOrName string
recordId string recordId string
expands []string expands []string
fetchFunc daos.ExpandFetchFunc fetchFunc daos.ExpandFetchFunc
expectExpandProps int expectExpandProps int
expectExpandFailures int expectExpandFailures int
}{ }{
// empty expand
{ {
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "empty expand",
"demo4",
"i9naidtvr6qsgb4",
[]string{}, []string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 0,
0, 0,
}, },
// empty fetchFunc
{ {
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "empty fetchFunc",
[]string{"onerel", "manyrels.onerel.manyrels"}, "demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil, nil,
0, 0,
2, 2,
}, },
// fetchFunc with error
{ {
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "fetchFunc with error",
[]string{"onerel", "manyrels.onerel.manyrels"}, "demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error") return nil, errors.New("test error")
}, },
0, 0,
2, 2,
}, },
// invalid missing first level expand
{ {
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "missing relation field",
[]string{"invalid"}, "demo4",
"i9naidtvr6qsgb4",
[]string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 0,
1, 1,
}, },
// invalid missing second level expand
{ {
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "existing, but non-relation type field",
[]string{"manyrels.invalid"}, "demo4",
"i9naidtvr6qsgb4",
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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, 0,
1, 1,
}, },
// expand normalizations
{ {
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "invalid/missing second level expand",
[]string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "}, "demo4",
"qzaqccwrmva4o1n",
[]string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil) return app.Dao().FindRecordsByIds(c.Id, ids, nil)
}, },
3,
0, 0,
},
// 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, 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, 0,
}, },
// maxExpandDepth reached
{ {
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "no rels to expand",
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"}, "users",
"oap640cot4yru2s",
[]string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) { 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,
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, 6,
0, 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 { for _, s := range scenarios {
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId) record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc) failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
if len(failed) != s.expectExpandFailures { if len(failed) != s.expectExpandFailures {
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed) t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
} }
encoded, _ := json.Marshal(record) encoded, _ := json.Marshal(record)
encodedStr := string(encoded) encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, "@expand") totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps { if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr) t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
} }
} }
} }
+198
View File
@@ -0,0 +1,198 @@
package daos
import (
"fmt"
"strings"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
// SyncRecordTableSchema compares the two provided collections
// and applies the necessary related record table changes.
//
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
// create
if oldCollection == nil {
cols := map[string]string{
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
}
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
// add schema field definitions
for _, field := range newCollection.Schema.Fields() {
cols[field.Name] = field.ColDefinition()
}
// create table
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add named index on the base `created` column
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := dao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
return err
}
}
return nil
}
// update
return dao.RunInTransaction(func(txDao *Dao) error {
oldTableName := oldCollection.Name
newTableName := newCollection.Name
oldSchema := oldCollection.Schema
newSchema := newCollection.Schema
deletedFieldNames := []string{}
renamedFieldNames := map[string]string{}
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
if err != nil {
return err
}
}
// check for deleted columns
for _, oldField := range oldSchema.Fields() {
if f := newSchema.GetFieldById(oldField.Id); f != nil {
continue // exist
}
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
if err != nil {
return err
}
deletedFieldNames = append(deletedFieldNames, oldField.Name)
}
// check for new or renamed columns
toRename := map[string]string{}
for _, field := range newSchema.Fields() {
oldField := oldSchema.GetFieldById(field.Id)
// 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 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
}
renamedFieldNames[oldField.Name] = field.Name
}
}
// set the actual columns name
for tempName, actualName := range toRename {
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
if err != nil {
return err
}
}
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
})
}
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
return nil // nothing to sync
}
refs, err := dao.FindCollectionReferences(collection)
if err != nil {
return err
}
for refCollection, refFields := range refs {
for _, refField := range refFields {
options, _ := refField.Options.(*schema.RelationOptions)
if options == nil {
continue
}
// remove deleted (if any)
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
for old, new := range renamedFieldNames {
for i, name := range newDisplayFields {
if name == old {
newDisplayFields[i] = new
}
}
}
// has changes
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
options.DisplayFields = newDisplayFields
// direct collection save to prevent self-referencing
// recursion and unnecessary records table sync checks
if err := dao.Save(refCollection); err != nil {
return err
}
}
}
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package daos_test
import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestSyncRecordTableSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
updatedCollection.Name = "demo_renamed"
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
updatedCollection.Schema.AddField(
&schema.SchemaField{
Name: "new_field",
Type: schema.FieldTypeEmail,
},
)
updatedCollection.Schema.AddField(
&schema.SchemaField{
Id: updatedCollection.Schema.GetFieldByName("title").Id,
Name: "title_renamed",
Type: schema.FieldTypeEmail,
},
)
scenarios := []struct {
newCollection *models.Collection
oldCollection *models.Collection
expectedTableName string
expectedColumns []string
}{
// new base collection
{
&models.Collection{
Name: "new_table",
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "test",
Type: schema.FieldTypeText,
},
),
},
nil,
"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,
"demo3",
[]string{"id", "created", "updated", "title", "active"},
},
// renamed table, deleted column, renamed columnd and new column
{
updatedCollection,
oldCollection,
"demo_renamed",
[]string{"id", "created", "updated", "title_renamed", "new_field"},
},
}
for i, scenario := range scenarios {
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
if err != nil {
t.Errorf("(%d) %v", i, err)
continue
}
if !app.Dao().HasTable(scenario.newCollection.Name) {
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
}
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
if len(cols) != len(scenario.expectedColumns) {
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
}
for _, c := range cols {
if !list.ExistInSlice(c, scenario.expectedColumns) {
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
}
}
}
}
+628 -195
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -59,7 +59,7 @@ func TestRequestsStats(t *testing.T) {
tests.MockRequestLogsData(app) 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) now := time.Now().UTC().Format(types.DefaultDateLayout)
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now}) exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
@@ -84,10 +84,10 @@ func TestDeleteOldRequests(t *testing.T) {
date string date string
expectedTotal int expectedTotal int
}{ }{
{"2022-01-01 10:00:00.000", 2}, // no requests to delete before that time {"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
{"2022-05-01 11:00:00.000", 1}, // only 1 request should have left {"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
{"2022-05-03 11:00:00.000", 0}, // no more requests should have left {"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
{"2022-05-04 11:00:00.000", 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 { 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)
}
}
+42 -3
View File
@@ -1,16 +1,19 @@
package daos package daos
import ( import (
"fmt"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
) )
// HasTable checks if a table with the provided name exists (case insensitive). // HasTable checks if a table (or view) with the provided name exists (case insensitive).
func (dao *Dao) HasTable(tableName string) bool { func (dao *Dao) HasTable(tableName string) bool {
var exists bool var exists bool
err := dao.DB().Select("count(*)"). err := dao.DB().Select("count(*)").
From("sqlite_schema"). From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "table"}). AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})). AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
Limit(1). Limit(1).
Row(&exists) Row(&exists)
@@ -29,9 +32,45 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
return columns, err return columns, err
} }
// GetTableInfo returns the `table_info` pragma result for the specified table.
func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
info := []*models.TableInfoRow{}
err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
All(&info)
if err != nil {
return nil, err
}
// mattn/go-sqlite3 doesn't throw an error on invalid or missing table
// so we additionally have to check whether the loaded info result is nonempty
if len(info) == 0 {
return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
}
return info, nil
}
// DeleteTable drops the specified table. // DeleteTable drops the specified table.
//
// This method is a no-op if a table with the provided name doesn't exist.
//
// Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
func (dao *Dao) DeleteTable(tableName string) error { func (dao *Dao) DeleteTable(tableName string) error {
_, err := dao.DB().DropTable(tableName).Execute() _, err := dao.DB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}",
tableName,
)).Execute()
return err
}
// 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 return err
} }
+72 -12
View File
@@ -1,7 +1,11 @@
package daos_test package daos_test
import ( import (
"context"
"database/sql"
"encoding/json"
"testing" "testing"
"time"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
@@ -20,12 +24,13 @@ func TestHasTable(t *testing.T) {
{"_admins", true}, {"_admins", true},
{"demo3", true}, {"demo3", true},
{"DEMO3", true}, // table names are case insensitives by default {"DEMO3", true}, // table names are case insensitives by default
{"view1", true}, // view
} }
for i, scenario := range scenarios { for i, scenario := range scenarios {
result := app.Dao().HasTable(scenario.tableName) result := app.Dao().HasTable(scenario.tableName)
if result != scenario.expected { if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
} }
} }
} }
@@ -42,21 +47,50 @@ func TestGetTableColumns(t *testing.T) {
{"_params", []string{"id", "key", "value", "created", "updated"}}, {"_params", []string{"id", "key", "value", "created", "updated"}},
} }
for i, scenario := range scenarios { for i, s := range scenarios {
columns, _ := app.Dao().GetTableColumns(scenario.tableName) columns, _ := app.Dao().GetTableColumns(s.tableName)
if len(columns) != len(scenario.expected) { if len(columns) != len(s.expected) {
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expected, columns) t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
continue
} }
for _, c := range columns { for _, c := range columns {
if !list.ExistInSlice(c, scenario.expected) { if !list.ExistInSlice(c, s.expected) {
t.Errorf("(%d) Didn't expect column %s", i, c) t.Errorf("[%d] Didn't expect column %s", i, c)
} }
} }
} }
} }
func TestGetTableInfo(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
tableName string
expected string
}{
{"", "null"},
{"missing", "null"},
{
"_admins",
`[{"PK":1,"Index":0,"Name":"id","Type":"TEXT","NotNull":false,"DefaultValue":null},{"PK":0,"Index":1,"Name":"avatar","Type":"INTEGER","NotNull":true,"DefaultValue":0},{"PK":0,"Index":2,"Name":"email","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":3,"Name":"tokenKey","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":4,"Name":"passwordHash","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":5,"Name":"lastResetSentAt","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":6,"Name":"created","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":7,"Name":"updated","Type":"TEXT","NotNull":true,"DefaultValue":""}]`,
},
}
for i, s := range scenarios {
rows, _ := app.Dao().GetTableInfo(s.tableName)
raw, _ := json.Marshal(rows)
rawStr := string(raw)
if rawStr != s.expected {
t.Errorf("[%d] Expected \n%v, \ngot \n%v", i, s.expected, rawStr)
}
}
}
func TestDeleteTable(t *testing.T) { func TestDeleteTable(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -66,16 +100,42 @@ func TestDeleteTable(t *testing.T) {
expectError bool expectError bool
}{ }{
{"", true}, {"", true},
{"test", true}, {"test", false}, // missing tables are ignored
{"_admins", false}, {"_admins", false},
{"demo3", false}, {"demo3", false},
} }
for i, scenario := range scenarios { for i, s := range scenarios {
err := app.Dao().DeleteTable(scenario.tableName) err := app.Dao().DeleteTable(s.tableName)
hasErr := err != nil hasErr := err != nil
if hasErr != scenario.expectError { if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr) t.Errorf("[%d] Expected hasErr %v, got %v", i, s.expectError, hasErr)
} }
} }
} }
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])
}
}
-282
View File
@@ -1,282 +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 := make([]string, len(users))
usersMap := map[string]*models.User{}
for i, user := range users {
ids[i] = 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 non-empty 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.Not(dbx.HashExp{"email": ""})).
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
})
}
-275
View File
@@ -1,275 +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
}{
{"", true},
{"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)
}
}
+583
View File
@@ -0,0 +1,583 @@
package daos
import (
"errors"
"fmt"
"io"
"regexp"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/tokenizer"
"github.com/pocketbase/pocketbase/tools/types"
)
// DeleteView drops the specified view name.
//
// This method is a no-op if a view with the provided name doesn't exist.
//
// Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input!
func (dao *Dao) DeleteView(name string) error {
_, err := dao.DB().NewQuery(fmt.Sprintf(
"DROP VIEW IF EXISTS {{%s}}",
name,
)).Execute()
return err
}
// SaveView creates (or updates already existing) persistent SQL view.
//
// Be aware that this method is vulnerable to SQL injection and the
// "selectQuery" argument must come only from trusted input!
func (dao *Dao) SaveView(name string, selectQuery string) error {
return dao.RunInTransaction(func(txDao *Dao) error {
// delete old view (if exists)
if err := txDao.DeleteView(name); err != nil {
return err
}
trimmed := strings.Trim(selectQuery, ";")
// try to eagerly detect multiple inline statements
tk := tokenizer.NewFromString(trimmed)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
}
// (re)create the view
//
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution.
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
return err
}
// fetch the view table info to ensure that the view was created
// because missing tables or columns won't return an error
if _, err := txDao.GetTableInfo(name); err != nil {
return err
}
return nil
})
}
// CreateViewSchema creates a new view schema from the provided select query.
//
// There are some caveats:
// - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
result := schema.NewSchema()
suggestedFields, err := dao.parseQueryToFields(selectQuery)
if err != nil {
return result, err
}
// note wrap in a transaction in case the selectQuery contains
// multiple statements allowing us to rollback on any error
txErr := dao.RunInTransaction(func(txDao *Dao) error {
tempView := "_temp_" + security.PseudorandomString(5)
// create a temp view with the provided query
if err := txDao.SaveView(tempView, selectQuery); err != nil {
return err
}
defer txDao.DeleteView(tempView)
// extract the generated view table info
info, err := txDao.GetTableInfo(tempView)
if err != nil {
return err
}
var hasId bool
for _, row := range info {
if row.Name == schema.FieldNameId {
hasId = true
}
if list.ExistInSlice(row.Name, schema.BaseModelFieldNames()) {
continue // skip base model fields since they are not part of the schema
}
var field *schema.SchemaField
if f, ok := suggestedFields[row.Name]; ok {
field = f.field
} else {
field = defaultViewField(row.Name)
}
result.AddField(field)
}
if !hasId {
return errors.New("missing required id column (you ca use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
}
return nil
})
return result, txErr
}
// FindRecordByViewFile returns the original models.Record of the
// provided view collection file.
func (dao *Dao) FindRecordByViewFile(
viewCollectionNameOrId string,
fileFieldName string,
filename string,
) (*models.Record, error) {
view, err := dao.FindCollectionByNameOrId(viewCollectionNameOrId)
if err != nil {
return nil, err
}
if !view.IsView() {
return nil, errors.New("not a view collection")
}
var findFirstNonViewQueryFileField func(int) (*queryField, error)
findFirstNonViewQueryFileField = func(level int) (*queryField, error) {
// check the level depth to prevent infinite circular recursion
// (the limit is arbitrary and may change in the future)
if level > 5 {
return nil, errors.New("reached the max recursion level of view collection file field queries")
}
queryFields, err := dao.parseQueryToFields(view.ViewOptions().Query)
if err != nil {
return nil, err
}
for _, item := range queryFields {
if item.collection == nil ||
item.original == nil ||
item.field.Name != fileFieldName {
continue
}
if item.collection.IsView() {
view = item.collection
fileFieldName = item.original.Name
return findFirstNonViewQueryFileField(level + 1)
}
return item, nil
}
return nil, errors.New("no query file field found")
}
qf, err := findFirstNonViewQueryFileField(1)
if err != nil {
return nil, err
}
cleanFieldName := inflector.Columnify(qf.original.Name)
record := &models.Record{}
err = dao.RecordQuery(qf.collection).
InnerJoin(fmt.Sprintf(
// note: the case is used to normalize the value access
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{_je_file}}`,
cleanFieldName, cleanFieldName, cleanFieldName,
), dbx.HashExp{"_je_file.value": filename}).
Limit(1).
One(record)
if err != nil {
return nil, err
}
return record, nil
}
// -------------------------------------------------------------------
// Raw query to schema helpers
// -------------------------------------------------------------------
type queryField struct {
// field is the final resolved field.
field *schema.SchemaField
// collection refers to the original field's collection model.
// It could be nil if the found query field is not from a collection schema.
collection *models.Collection
// original is the original found collection field.
// It could be nil if the found query field is not from a collection schema.
original *schema.SchemaField
}
func defaultViewField(name string) *schema.SchemaField {
return &schema.SchemaField{
Name: name,
Type: schema.FieldTypeJson,
}
}
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
p := new(identifiersParser)
if err := p.parse(selectQuery); err != nil {
return nil, err
}
collections, err := dao.findCollectionsByIdentifiers(p.tables)
if err != nil {
return nil, err
}
result := make(map[string]*queryField, len(p.columns))
var mainTable identifier
if len(p.tables) > 0 {
mainTable = p.tables[0]
}
for _, col := range p.columns {
colLower := strings.ToLower(col.original)
// numeric expression cast
if strings.Contains(colLower, "(") &&
(strings.HasPrefix(colLower, "count(") ||
strings.HasPrefix(colLower, "total(") ||
strings.Contains(colLower, " as numeric") ||
strings.Contains(colLower, " as real") ||
strings.Contains(colLower, " as int") ||
strings.Contains(colLower, " as integer") ||
strings.Contains(colLower, " as decimal")) {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeNumber,
},
}
continue
}
parts := strings.Split(col.original, ".")
var fieldName string
var collection *models.Collection
var isMainTableField bool
if len(parts) == 2 {
fieldName = parts[1]
collection = collections[parts[0]]
isMainTableField = parts[0] == mainTable.alias
} else {
fieldName = parts[0]
collection = collections[mainTable.alias]
isMainTableField = true
}
// fallback to the default field if the found column is not from a collection schema
if collection == nil {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
}
continue
}
if fieldName == "*" {
return nil, errors.New("dynamic column names are not supported")
}
// find the first field by name (case insensitive)
var field *schema.SchemaField
for _, f := range collection.Schema.Fields() {
if strings.EqualFold(f.Name, fieldName) {
field = f
break
}
}
if field != nil {
clone := *field
clone.Name = col.alias
result[col.alias] = &queryField{
field: &clone,
collection: collection,
original: field,
}
continue
}
if fieldName == schema.FieldNameId && !isMainTableField {
// convert to relation since it is a direct id reference to non-maintable collection
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: types.Pointer(1),
CollectionId: collection.Id,
},
},
collection: collection,
}
} else if fieldName == schema.FieldNameCreated || fieldName == schema.FieldNameUpdated {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeDate,
},
collection: collection,
}
} else if fieldName == schema.FieldNameUsername && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeText,
},
collection: collection,
}
} else if fieldName == schema.FieldNameEmail && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeEmail,
},
collection: collection,
}
} else if (fieldName == schema.FieldNameVerified || fieldName == schema.FieldNameEmailVisibility) && collection.IsAuth() {
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
Type: schema.FieldTypeBool,
},
collection: collection,
}
} else {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
collection: collection,
}
}
}
return result, nil
}
func (dao *Dao) findCollectionsByIdentifiers(tables []identifier) (map[string]*models.Collection, error) {
names := make([]any, 0, len(tables))
for _, table := range tables {
if strings.Contains(table.alias, "(") {
continue // skip expressions
}
names = append(names, table.original)
}
if len(names) == 0 {
return nil, nil
}
result := make(map[string]*models.Collection, len(names))
collections := make([]*models.Collection, 0, len(names))
err := dao.CollectionQuery().
AndWhere(dbx.In("name", names...)).
All(&collections)
if err != nil {
return nil, err
}
for _, table := range tables {
for _, collection := range collections {
if collection.Name == table.original {
result[table.alias] = collection
}
}
}
return result, nil
}
// -------------------------------------------------------------------
// Raw query identifiers parser
// -------------------------------------------------------------------
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(inner join|outer join|left join|right join|join)\s+?`)
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group by|having|order|limit|with)\s+?`)
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
type identifier struct {
original string
alias string
}
type identifiersParser struct {
columns []identifier
tables []identifier
}
func (p *identifiersParser) parse(selectQuery string) error {
str := strings.Trim(selectQuery, ";")
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
str = commentsReplaceRegex.ReplaceAllString(str, "")
tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t')
tk.KeepSeparator(true)
var skip bool
var partType string
var activeBuilder *strings.Builder
var selectParts strings.Builder
var fromParts strings.Builder
var joinParts strings.Builder
for {
token, err := tk.Scan()
if err != nil {
if err != io.EOF {
return err
}
break
}
trimmed := strings.ToLower(strings.TrimSpace(token))
switch trimmed {
case "select":
skip = false
partType = "select"
activeBuilder = &selectParts
case "from":
skip = false
partType = "from"
activeBuilder = &fromParts
case "_join_":
skip = false
// the previous part was also a join
if partType == "join" {
joinParts.WriteString(",")
}
partType = "join"
activeBuilder = &joinParts
case "_discard_":
// do nothing...
skip = true
default:
isJoin := partType == "join"
if isJoin && trimmed == "on" {
skip = true
}
if !skip && activeBuilder != nil {
activeBuilder.WriteString(" ")
activeBuilder.WriteString(token)
}
}
}
selects, err := extractIdentifiers(selectParts.String())
if err != nil {
return err
}
froms, err := extractIdentifiers(fromParts.String())
if err != nil {
return err
}
joins, err := extractIdentifiers(joinParts.String())
if err != nil {
return err
}
p.columns = selects
p.tables = froms
p.tables = append(p.tables, joins...)
return nil
}
func extractIdentifiers(rawExpression string) ([]identifier, error) {
rawTk := tokenizer.NewFromString(rawExpression)
rawTk.Separators(',')
rawIdentifiers, err := rawTk.ScanAll()
if err != nil {
return nil, err
}
result := make([]identifier, 0, len(rawIdentifiers))
for _, rawIdentifier := range rawIdentifiers {
tk := tokenizer.NewFromString(rawIdentifier)
tk.Separators(' ', '\n', '\t')
parts, err := tk.ScanAll()
if err != nil {
return nil, err
}
resolved, err := identifierFromParts(parts)
if err != nil {
return nil, err
}
result = append(result, resolved)
}
return result, nil
}
func identifierFromParts(parts []string) (identifier, error) {
var result identifier
switch len(parts) {
case 3:
if !strings.EqualFold(parts[1], "as") {
return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
}
result.original = parts[0]
result.alias = parts[2]
case 2:
result.original = parts[0]
result.alias = parts[1]
case 1:
subParts := strings.Split(parts[0], ".")
result.original = parts[0]
result.alias = subParts[len(subParts)-1]
default:
return result, fmt.Errorf(`invalid identifier parts %v`, parts)
}
result.original = trimRawIdentifier(result.original)
result.alias = trimRawIdentifier(result.alias)
return result, nil
}
func trimRawIdentifier(rawIdentifier string) string {
const trimChars = "`\"[];"
parts := strings.Split(rawIdentifier, ".")
for i := range parts {
parts[i] = strings.Trim(parts[i], trimChars)
}
return strings.Join(parts, ".")
}
+539
View File
@@ -0,0 +1,539 @@
package daos_test
import (
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestDeleteView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
viewName string
expectError bool
}{
{"", true},
{"demo1", true}, // not a view table
{"missing", false}, // missing or already deleted
{"view1", false}, // existing
{"VieW1", false}, // view names are case insensitives
}
for i, s := range scenarios {
err := app.Dao().DeleteView(s.viewName)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
}
}
}
func TestSaveView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
scenarioName string
viewName string
query string
expectError bool
expectColumns []string
}{
{
"empty name and query",
"",
"",
true,
nil,
},
{
"empty name",
"",
"select * from _admins",
true,
nil,
},
{
"empty query",
"123Test",
"",
true,
nil,
},
{
"invalid query",
"123Test",
"123 456",
true,
nil,
},
{
"missing table",
"123Test",
"select * from missing",
true,
nil,
},
{
"non select query",
"123Test",
"drop table _admins",
true,
nil,
},
{
"multiple select queries",
"123Test",
"select *, count(id) as c from _admins; select * from demo1;",
true,
nil,
},
{
"try to break the parent parenthesis",
"123Test",
"select *, count(id) as c from `_admins`)",
true,
nil,
},
{
"simple select query (+ trimmed semicolon)",
"123Test",
";select *, count(id) as c from _admins;",
false,
[]string{
"id", "created", "updated",
"passwordHash", "tokenKey", "email",
"lastResetSentAt", "avatar", "c",
},
},
{
"update old view with new query",
"123Test",
"select 1 as test from _admins",
false,
[]string{"test"},
},
}
for _, s := range scenarios {
err := app.Dao().SaveView(s.viewName, s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
infoRows, err := app.Dao().GetTableInfo(s.viewName)
if err != nil {
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
continue
}
if len(s.expectColumns) != len(infoRows) {
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
continue
}
for _, row := range infoRows {
if !list.ExistInSlice(row.Name, s.expectColumns) {
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
}
}
}
}
func TestCreateViewSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
query string
expectError bool
expectFields map[string]string // name-type pairs
}{
{
"empty query",
"",
true,
nil,
},
{
"invalid query",
"test 123456",
true,
nil,
},
{
"missing table",
"select * from missing",
true,
nil,
},
{
"query with wildcard column",
"select a.id, a.* from demo1 a",
true,
nil,
},
{
"query without id",
"select text, url, created, updated from demo1",
true,
nil,
},
{
"query with comments",
`
select
-- test single line
id,
text,
/* multi
line comment */
url, created, updated from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"url": schema.FieldTypeUrl,
},
},
{
"query with all fields and quoted identifiers",
`
select
"id",
"created",
"updated",
[text],
` + "`bool`" + `,
"url",
"select_one",
"select_many",
"file_one",
"demo1"."file_many",
` + "`demo1`." + "`number`" + ` number_alias,
"email",
"datetime",
"json",
"rel_one",
"rel_many"
from demo1
`,
false,
map[string]string{
"text": schema.FieldTypeText,
"bool": schema.FieldTypeBool,
"url": schema.FieldTypeUrl,
"select_one": schema.FieldTypeSelect,
"select_many": schema.FieldTypeSelect,
"file_one": schema.FieldTypeFile,
"file_many": schema.FieldTypeFile,
"number_alias": schema.FieldTypeNumber,
"email": schema.FieldTypeEmail,
"datetime": schema.FieldTypeDate,
"json": schema.FieldTypeJson,
"rel_one": schema.FieldTypeRelation,
"rel_many": schema.FieldTypeRelation,
},
},
{
"query with indirect relations fields",
"select a.id, b.id as bid, b.created from demo1 as a left join demo2 b",
false,
map[string]string{
"bid": schema.FieldTypeRelation,
},
},
{
"query with multiple froms, joins and style of aliasses",
`
select
a.id as id,
b.id as bid,
lj.id cid,
ij.id as did,
a.bool,
_admins.id as eid,
_admins.email
from demo1 a, demo2 as b
left join demo3 lj on lj.id = 123
inner join demo4 as ij on ij.id = 123
join _admins
where 1=1
group by a.id
limit 10
`,
false,
map[string]string{
"bid": schema.FieldTypeRelation,
"cid": schema.FieldTypeRelation,
"did": schema.FieldTypeRelation,
"bool": schema.FieldTypeBool,
"eid": schema.FieldTypeJson, // not from collection
"email": schema.FieldTypeJson, // not from collection
},
},
{
"query with numeric casts",
`select
a.id,
count(a.id) count,
cast(a.id as int) cast_int,
cast(a.id as integer) cast_integer,
cast(a.id as real) cast_real,
cast(a.id as decimal) cast_decimal,
cast(a.id as numeric) cast_numeric,
avg(a.id) avg,
sum(a.id) sum,
total(a.id) total,
min(a.id) min,
max(a.id) max
from demo1 a`,
false,
map[string]string{
"count": schema.FieldTypeNumber,
"total": schema.FieldTypeNumber,
"cast_int": schema.FieldTypeNumber,
"cast_integer": schema.FieldTypeNumber,
"cast_real": schema.FieldTypeNumber,
"cast_decimal": schema.FieldTypeNumber,
"cast_numeric": schema.FieldTypeNumber,
// json because they are nullable
"sum": schema.FieldTypeJson,
"avg": schema.FieldTypeJson,
"min": schema.FieldTypeJson,
"max": schema.FieldTypeJson,
},
},
{
"query with reserved auth collection fields",
`
select
a.id,
a.username,
a.email,
a.emailVisibility,
a.verified,
demo1.id relid
from users a
left join demo1
`,
false,
map[string]string{
"username": schema.FieldTypeText,
"email": schema.FieldTypeEmail,
"emailVisibility": schema.FieldTypeBool,
"verified": schema.FieldTypeBool,
"relid": schema.FieldTypeRelation,
},
},
{
"query with unknown fields and aliases",
`select
id,
id as id2,
text as text_alias,
url as url_alias,
"demo1"."bool" as bool_alias,
number as number_alias,
created created_alias,
updated updated_alias,
123 as custom
from demo1
`,
false,
map[string]string{
"id2": schema.FieldTypeJson,
"text_alias": schema.FieldTypeText,
"url_alias": schema.FieldTypeUrl,
"bool_alias": schema.FieldTypeBool,
"number_alias": schema.FieldTypeNumber,
"created_alias": schema.FieldTypeDate,
"updated_alias": schema.FieldTypeDate,
"custom": schema.FieldTypeJson,
},
},
}
for _, s := range scenarios {
result, err := app.Dao().CreateViewSchema(s.query)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if len(s.expectFields) != len(result.Fields()) {
serialized, _ := json.Marshal(result)
t.Errorf("[%s] Expected %d fields, got %d: \n%s", s.name, len(s.expectFields), len(result.Fields()), serialized)
continue
}
for name, typ := range s.expectFields {
field := result.GetFieldByName(name)
if field == nil {
t.Errorf("[%s] Expected to find field %s, got nil", s.name, name)
continue
}
if field.Type != typ {
t.Errorf("[%s] Expected field %s to be %q, got %s", s.name, name, typ, field.Type)
continue
}
}
}
}
func TestFindRecordByViewFile(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
prevCollection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
totalLevels := 6
// create collection view mocks
fileOneAlias := "file_one one0"
fileManyAlias := "file_many many0"
mockCollections := make([]*models.Collection, 0, totalLevels)
for i := 0; i <= totalLevels; i++ {
view := new(models.Collection)
view.Type = models.CollectionTypeView
view.Name = fmt.Sprintf("_test_view%d", i)
view.SetOptions(&models.CollectionViewOptions{
Query: fmt.Sprintf(
"select id, %s, %s from %s",
fileOneAlias,
fileManyAlias,
prevCollection.Name,
),
})
// save view
if err := app.Dao().SaveCollection(view); err != nil {
t.Fatalf("Failed to save view%d: %v", i, err)
}
mockCollections = append(mockCollections, view)
prevCollection = view
fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1)
fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1)
}
fileOneName := "test_d61b33QdDU.txt"
fileManyName := "test_QZFjKjXchk.txt"
expectedRecordId := "84nmscqy84lsi1t"
scenarios := []struct {
name string
collectionNameOrId string
fileFieldName string
filename string
expectError bool
expectRecordId string
}{
{
"missing collection",
"missing",
"a",
fileOneName,
true,
"",
},
{
"non-view collection",
"demo1",
"file_one",
fileOneName,
true,
"",
},
{
"view collection after the max recursion limit",
mockCollections[totalLevels-1].Name,
fmt.Sprintf("one%d", totalLevels-1),
fileOneName,
true,
"",
},
{
"first view collection (single file)",
mockCollections[0].Name,
"one0",
fileOneName,
false,
expectedRecordId,
},
{
"first view collection (many files)",
mockCollections[0].Name,
"many0",
fileManyName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (single file)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("one%d", totalLevels-2),
fileOneName,
false,
expectedRecordId,
},
{
"last view collection before the recursion limit (many files)",
mockCollections[totalLevels-2].Name,
fmt.Sprintf("many%d", totalLevels-2),
fileManyName,
false,
expectedRecordId,
},
}
for _, s := range scenarios {
record, err := app.Dao().FindRecordByViewFile(
s.collectionNameOrId,
s.fileFieldName,
s.filename,
)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
continue
}
if hasErr {
continue
}
if record.Id != s.expectRecordId {
t.Errorf("[%s] Expected recordId %q, got %q", s.name, s.expectRecordId, record.Id)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
# ignore everything
/*
# exclude from the ignore filter
!.gitignore
!main.go
+83 -4
View File
@@ -2,21 +2,91 @@ package main
import ( import (
"log" "log"
"os"
"path/filepath"
"strings"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
) )
func main() { func main() {
app := pocketbase.New() 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)",
)
var queryTimeout int
app.RootCmd.PersistentFlags().IntVar(
&queryTimeout,
"queryTimeout",
30,
"the default SELECT queries timeout in seconds",
)
app.RootCmd.ParseFlags(os.Args[1:])
// ---------------------------------------------------------------
// Plugins and hooks:
// ---------------------------------------------------------------
// load js pb_migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
Dir: migrationsDir,
})
// migrate command (with js templates)
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
return nil
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error { app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// serves static files from the provided public dir (if exists) // serves static files from the provided public dir (if exists)
subFs := echo.MustSubFS(e.Router.Filesystem, "pb_public") e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
e.Router.GET("/*", apis.StaticDirectoryHandler(subFs, false))
return nil return nil
}) })
@@ -24,3 +94,12 @@ func main() {
log.Fatal(err) 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")
}
+39 -40
View File
@@ -1,6 +1,7 @@
package forms package forms
import ( import (
"database/sql"
"errors" "errors"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -10,72 +11,70 @@ import (
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
) )
// AdminLogin specifies an admin email/pass login form. // AdminLogin is an admin email/pass login form.
type AdminLogin struct { type AdminLogin struct {
config AdminLoginConfig app core.App
dao *daos.Dao
Email string `form:"email" json:"email"` Identity string `form:"identity" json:"identity"`
Password string `form:"password" json:"password"` Password string `form:"password" json:"password"`
} }
// AdminLoginConfig is the [AdminLogin] factory initializer config. // NewAdminLogin creates a new [AdminLogin] form initialized with
// the provided [core.App] instance.
// //
// NB! App is a required struct member. // If you want to submit the form as part of a transaction,
type AdminLoginConfig struct { // you can change the default Dao via [SetDao()].
App core.App
Dao *daos.Dao
}
// NewAdminLogin creates a new [AdminLogin] form with initializer
// config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewAdminLoginWithConfig] with explicitly set Dao.
func NewAdminLogin(app core.App) *AdminLogin { func NewAdminLogin(app core.App) *AdminLogin {
return NewAdminLoginWithConfig(AdminLoginConfig{ return &AdminLogin{
App: app, app: app,
}) dao: app.Dao(),
}
} }
// NewAdminLoginWithConfig creates a new [AdminLogin] form // SetDao replaces the default form Dao instance with the provided one.
// with the provided config or panics on invalid configuration. func (form *AdminLogin) SetDao(dao *daos.Dao) {
func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin { form.dao = dao
form := &AdminLogin{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.Dao == nil {
form.config.Dao = form.config.App.Dao()
}
return form
} }
// Validate makes the form validatable by implementing [validation.Validatable] interface. // Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminLogin) Validate() error { func (form *AdminLogin) Validate() error {
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat), validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat),
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)), validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
) )
} }
// Submit validates and submits the admin form. // Submit validates and submits the admin form.
// On success returns the authorized admin model. // On success returns the authorized admin model.
func (form *AdminLogin) Submit() (*models.Admin, error) { //
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *AdminLogin) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
if err := form.Validate(); err != nil { if err := form.Validate(); err != nil {
return nil, err return nil, err
} }
admin, err := form.config.Dao.FindAdminByEmail(form.Email) admin, fetchErr := form.dao.FindAdminByEmail(form.Identity)
if err != nil {
return nil, err // ignore not found errors to allow custom fetch implementations
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
return nil, fetchErr
} }
if admin.ValidatePassword(form.Password) { interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
return admin, nil admin = m
if admin == nil || !admin.ValidatePassword(form.Password) {
return errors.New("Invalid login credentials.")
}
return nil
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
} }
return nil, errors.New("Invalid login credentials.") return admin, nil
} }
+50 -44
View File
@@ -1,54 +1,15 @@
package forms_test package forms_test
import ( import (
"errors"
"testing" "testing"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
) )
func TestAdminLoginPanic(t *testing.T) { func TestAdminLoginValidateAndSubmit(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminLogin(nil)
}
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) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -62,14 +23,14 @@ func TestAdminLoginSubmit(t *testing.T) {
{"", "", true}, {"", "", true},
{"", "1234567890", true}, {"", "1234567890", true},
{"test@example.com", "", true}, {"test@example.com", "", true},
{"test", "1234567890", true}, {"test", "test", true},
{"missing@example.com", "1234567890", true}, {"missing@example.com", "1234567890", true},
{"test@example.com", "123456789", true}, {"test@example.com", "123456789", true},
{"test@example.com", "1234567890", false}, {"test@example.com", "1234567890", false},
} }
for i, s := range scenarios { for i, s := range scenarios {
form.Email = s.email form.Identity = s.email
form.Password = s.password form.Password = s.password
admin, err := form.Submit() admin, err := form.Submit()
@@ -88,3 +49,48 @@ func TestAdminLoginSubmit(t *testing.T) {
} }
} }
} }
func TestAdminLoginInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewAdminLogin(testApp)
form.Identity = "test@example.com"
form.Password = "123456"
var interceptorAdmin *models.Admin
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptor1Called = true
return next(admin)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptorAdmin = admin
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 interceptorAdmin == nil || interceptorAdmin.Email != form.Identity {
t.Fatalf("Expected Admin model with email %s, got %v", form.Identity, interceptorAdmin)
}
}
+31 -40
View File
@@ -8,55 +8,41 @@ import (
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
) )
// AdminPasswordResetConfirm specifies an admin password reset confirmation form. // AdminPasswordResetConfirm is an admin password reset confirmation form.
type AdminPasswordResetConfirm struct { type AdminPasswordResetConfirm struct {
config AdminPasswordResetConfirmConfig app core.App
dao *daos.Dao
Token string `form:"token" json:"token"` Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"` Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
} }
// AdminPasswordResetConfirmConfig is the [AdminPasswordResetConfirm] factory initializer config.
//
// NB! App is required struct member.
type AdminPasswordResetConfirmConfig struct {
App core.App
Dao *daos.Dao
}
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm] // NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
// form with initializer config created from the provided [core.App] instance. // form initialized with from the provided [core.App] instance.
// //
// If you want to submit the form as part of another transaction, use // If you want to submit the form as part of a transaction,
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set Dao. // you can change the default Dao via [SetDao()].
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm { func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{ return &AdminPasswordResetConfirm{
App: app, app: app,
}) dao: app.Dao(),
}
} }
// NewAdminPasswordResetConfirmWithConfig creates a new [AdminPasswordResetConfirm] // SetDao replaces the form Dao instance with the provided one.
// form with the provided config or panics on invalid configuration. //
func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConfig) *AdminPasswordResetConfirm { // This is useful if you want to use a specific transaction Dao instance
form := &AdminPasswordResetConfirm{config: config} // instead of the default app.Dao().
func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) {
if form.config.App == nil { form.dao = dao
panic("Missing required config.App instance.")
}
if form.config.Dao == nil {
form.config.Dao = form.config.App.Dao()
}
return form
} }
// Validate makes the form validatable by implementing [validation.Validatable] interface. // Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminPasswordResetConfirm) Validate() error { func (form *AdminPasswordResetConfirm) Validate() error {
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)), 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))), validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
) )
} }
@@ -67,10 +53,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
return nil // nothing to check return nil // nothing to check
} }
admin, err := form.config.Dao.FindAdminByToken( admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret)
v,
form.config.App.Settings().AdminPasswordResetToken.Secret,
)
if err != nil || admin == nil { if err != nil || admin == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.") return validation.NewError("validation_invalid_token", "Invalid or expired token.")
} }
@@ -80,14 +63,17 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
// Submit validates and submits the admin password reset confirmation form. // Submit validates and submits the admin password reset confirmation form.
// On success returns the updated admin model associated to `form.Token`. // On success returns the updated admin model associated to `form.Token`.
func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) { //
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *AdminPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
if err := form.Validate(); err != nil { if err := form.Validate(); err != nil {
return nil, err return nil, err
} }
admin, err := form.config.Dao.FindAdminByToken( admin, err := form.dao.FindAdminByToken(
form.Token, form.Token,
form.config.App.Settings().AdminPasswordResetToken.Secret, form.app.Settings().AdminPasswordResetToken.Secret,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -97,8 +83,13 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err return nil, err
} }
if err := form.config.Dao.SaveAdmin(admin); err != nil { interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
return nil, err admin = m
return form.dao.SaveAdmin(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
} }
return admin, nil return admin, nil
+81 -61
View File
@@ -1,24 +1,16 @@
package forms_test package forms_test
import ( import (
"errors"
"testing" "testing"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
) )
func TestAdminPasswordResetPanic(t *testing.T) { func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminPasswordResetConfirm(nil)
}
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -38,16 +30,23 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
{"test", "123", "123", true}, {"test", "123", "123", true},
{ {
// expired // expired
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
"1234567890", "1234567890",
"1234567890", "1234567890",
true, true,
}, },
{ {
// valid // valid with mismatched passwords
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"1234567890",
"1234567890", "1234567890",
"1234567891",
true,
},
{
// valid with matching passwords
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"1234567891",
"1234567891",
false, false,
}, },
} }
@@ -57,59 +56,29 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
form.Password = s.password form.Password = s.password
form.PasswordConfirm = s.passwordConfirm form.PasswordConfirm = s.passwordConfirm
err := form.Validate() interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
hasErr := err != nil return func(m *models.Admin) error {
if hasErr != s.expectError { interceptorCalls++
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) return next(m)
}
} }
}
}
func TestAdminPasswordResetConfirmSubmit(t *testing.T) { admin, err := form.Submit(interceptor)
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewAdminPasswordResetConfirm(app) // check interceptor calls
expectInterceptorCalls := 1
scenarios := []struct { if s.expectError {
token string expectInterceptorCalls = 0
password string }
passwordConfirm string if interceptorCalls != expectInterceptorCalls {
expectError bool t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}{ }
{"", "", "", true},
{"", "123", "", true},
{"", "", "123", true},
{"test", "", "", true},
{"test", "123", "", true},
{"test", "123", "123", true},
{
// expired
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"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
admin, err := form.Submit()
hasErr := err != nil hasErr := err != nil
if hasErr != s.expectError { if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
continue
} }
if s.expectError { if s.expectError {
@@ -128,3 +97,54 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
} }
} }
} }
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewAdminPasswordResetConfirm(testApp)
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc"
form.Password = "1234567891"
form.PasswordConfirm = "1234567891"
interceptorTokenKey := admin.TokenKey
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptor1Called = true
return next(admin)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptorTokenKey = admin.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 == admin.TokenKey {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+29 -40
View File
@@ -9,51 +9,35 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails" "github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
) )
// AdminPasswordResetRequest specifies an admin password reset request form. // AdminPasswordResetRequest is an admin password reset request form.
type AdminPasswordResetRequest struct { type AdminPasswordResetRequest struct {
config AdminPasswordResetRequestConfig app core.App
dao *daos.Dao
resendThreshold float64 // in seconds
Email string `form:"email" json:"email"` Email string `form:"email" json:"email"`
} }
// AdminPasswordResetRequestConfig is the [AdminPasswordResetRequest] factory initializer config.
//
// NB! App is required struct member.
type AdminPasswordResetRequestConfig struct {
App core.App
Dao *daos.Dao
ResendThreshold float64 // in seconds
}
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest] // NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
// form with initializer config created from the provided [core.App] instance. // form initialized with from the provided [core.App] instance.
// //
// If you want to submit the form as part of another transaction, use // If you want to submit the form as part of a transaction,
// [NewAdminPasswordResetRequestWithConfig] with explicitly set Dao. // you can change the default Dao via [SetDao()].
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest { func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{ return &AdminPasswordResetRequest{
App: app, app: app,
ResendThreshold: 120, // 2min dao: app.Dao(),
}) resendThreshold: 120, // 2min
}
} }
// NewAdminPasswordResetRequestWithConfig creates a new [AdminPasswordResetRequest] // SetDao replaces the default form Dao instance with the provided one.
// form with the provided config or panics on invalid configuration. func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) {
func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConfig) *AdminPasswordResetRequest { form.dao = dao
form := &AdminPasswordResetRequest{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.Dao == nil {
form.config.Dao = form.config.App.Dao()
}
return form
} }
// Validate makes the form validatable by implementing [validation.Validatable] interface. // Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -72,28 +56,33 @@ func (form *AdminPasswordResetRequest) Validate() error {
// Submit validates and submits the form. // Submit validates and submits the form.
// On success sends a password reset email to the `form.Email` admin. // On success sends a password reset email to the `form.Email` admin.
func (form *AdminPasswordResetRequest) Submit() error { //
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
if err := form.Validate(); err != nil { if err := form.Validate(); err != nil {
return err return err
} }
admin, err := form.config.Dao.FindAdminByEmail(form.Email) admin, err := form.dao.FindAdminByEmail(form.Email)
if err != nil { if err != nil {
return err return err
} }
now := time.Now().UTC() now := time.Now().UTC()
lastResetSentAt := admin.LastResetSentAt.Time() lastResetSentAt := admin.LastResetSentAt.Time()
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold { if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
return errors.New("You have already requested a password reset.") return errors.New("You have already requested a password reset.")
} }
if err := mails.SendAdminPasswordReset(form.config.App, admin); err != nil {
return err
}
// update last sent timestamp // update last sent timestamp
admin.LastResetSentAt = types.NowDateTime() admin.LastResetSentAt = types.NowDateTime()
return form.config.Dao.SaveAdmin(admin) return runInterceptors(admin, func(m *models.Admin) error {
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
return err
}
return form.dao.SaveAdmin(m)
}, interceptors...)
} }
+70 -41
View File
@@ -1,52 +1,15 @@
package forms_test package forms_test
import ( import (
"errors"
"testing" "testing"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
) )
func TestAdminPasswordResetRequestPanic(t *testing.T) { func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminPasswordResetRequest(nil)
}
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) {
testApp, _ := tests.NewTestApp() testApp, _ := tests.NewTestApp()
defer testApp.Cleanup() defer testApp.Cleanup()
@@ -70,7 +33,24 @@ func TestAdminPasswordResetRequestSubmit(t *testing.T) {
adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email) adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email)
err := form.Submit() interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(m *models.Admin) error {
interceptorCalls++
return next(m)
}
}
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 hasErr := err != nil
if hasErr != s.expectError { if hasErr != s.expectError {
@@ -92,3 +72,52 @@ func TestAdminPasswordResetRequestSubmit(t *testing.T) {
} }
} }
} }
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewAdminPasswordResetRequest(testApp)
form.Email = admin.Email
interceptorLastResetSentAt := admin.LastResetSentAt
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptor1Called = true
return next(admin)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func(admin *models.Admin) error {
interceptorLastResetSentAt = admin.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() == admin.LastResetSentAt.String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+20 -37
View File
@@ -9,10 +9,11 @@ import (
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
) )
// AdminUpsert specifies a [models.Admin] upsert (create/update) form. // AdminUpsert is a [models.Admin] upsert (create/update) form.
type AdminUpsert struct { type AdminUpsert struct {
config AdminUpsertConfig app core.App
admin *models.Admin dao *daos.Dao
admin *models.Admin
Id string `form:"id" json:"id"` Id string `form:"id" json:"id"`
Avatar int `form:"avatar" json:"avatar"` Avatar int `form:"avatar" json:"avatar"`
@@ -21,41 +22,17 @@ type AdminUpsert struct {
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
} }
// AdminUpsertConfig is the [AdminUpsert] factory initializer config.
//
// NB! App is a required struct member.
type AdminUpsertConfig struct {
App core.App
Dao *daos.Dao
}
// NewAdminUpsert creates a new [AdminUpsert] form with initializer // NewAdminUpsert creates a new [AdminUpsert] form with initializer
// config created from the provided [core.App] and [models.Admin] instances // config created from the provided [core.App] and [models.Admin] instances
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`). // (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
// //
// If you want to submit the form as part of another transaction, use // If you want to submit the form as part of a transaction,
// [NewAdminUpsertWithConfig] with explicitly set Dao. // you can change the default Dao via [SetDao()].
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert { func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
return NewAdminUpsertWithConfig(AdminUpsertConfig{
App: app,
}, admin)
}
// NewAdminUpsertWithConfig creates a new [AdminUpsert] form
// with the provided config and [models.Admin] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert {
form := &AdminUpsert{ form := &AdminUpsert{
config: config, app: app,
admin: admin, dao: app.Dao(),
} admin: admin,
if form.config.App == nil || form.admin == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.Dao == nil {
form.config.Dao = form.config.App.Dao()
} }
// load defaults // load defaults
@@ -66,6 +43,11 @@ func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *Ad
return form 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. // Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminUpsert) Validate() error { func (form *AdminUpsert) Validate() error {
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
@@ -75,6 +57,7 @@ func (form *AdminUpsert) Validate() error {
form.admin.IsNew(), form.admin.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength), validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex), validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.admin.TableName())),
).Else(validation.In(form.admin.Id)), ).Else(validation.In(form.admin.Id)),
), ),
validation.Field( validation.Field(
@@ -92,7 +75,7 @@ func (form *AdminUpsert) Validate() error {
validation.Field( validation.Field(
&form.Password, &form.Password,
validation.When(form.admin.IsNew(), validation.Required), validation.When(form.admin.IsNew(), validation.Required),
validation.Length(10, 100), validation.Length(10, 72),
), ),
validation.Field( validation.Field(
&form.PasswordConfirm, &form.PasswordConfirm,
@@ -105,7 +88,7 @@ func (form *AdminUpsert) Validate() error {
func (form *AdminUpsert) checkUniqueEmail(value any) error { func (form *AdminUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string) v, _ := value.(string)
if form.config.Dao.IsAdminEmailUnique(v, form.admin.Id) { if form.dao.IsAdminEmailUnique(v, form.admin.Id) {
return nil return nil
} }
@@ -116,7 +99,7 @@ func (form *AdminUpsert) checkUniqueEmail(value any) error {
// //
// You can optionally provide a list of InterceptorFunc to further // You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it. // modify the form behavior before persisting it.
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error { func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
if err := form.Validate(); err != nil { if err := form.Validate(); err != nil {
return err return err
} }
@@ -134,7 +117,7 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
form.admin.SetPassword(form.Password) form.admin.SetPassword(form.Password)
} }
return runInterceptors(func() error { return runInterceptors(form.admin, func(admin *models.Admin) error {
return form.config.Dao.SaveAdmin(form.admin) return form.dao.SaveAdmin(admin)
}, interceptors...) }, interceptors...)
} }
+16 -158
View File
@@ -6,35 +6,11 @@ import (
"fmt" "fmt"
"testing" "testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
) )
func TestAdminUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminUpsert(nil, nil)
}
func TestAdminUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminUpsert(app, nil)
}
func TestNewAdminUpsert(t *testing.T) { func TestNewAdminUpsert(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -54,125 +30,7 @@ func TestNewAdminUpsert(t *testing.T) {
} }
} }
func TestAdminUpsertValidate(t *testing.T) { func TestAdminUpsertValidateAndSubmit(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) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -189,7 +47,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
}, },
{ {
// update empty // update empty
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", "sywbhecnh46rhm0",
`{}`, `{}`,
false, false,
}, },
@@ -225,7 +83,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
}, },
{ {
// update failure - existing email // update failure - existing email
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", "sywbhecnh46rhm0",
`{ `{
"email": "test2@example.com" "email": "test2@example.com"
}`, }`,
@@ -233,7 +91,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
}, },
{ {
// update failure - mismatching passwords // update failure - mismatching passwords
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", "sywbhecnh46rhm0",
`{ `{
"password": "1234567890", "password": "1234567890",
"passwordConfirm": "1234567891" "passwordConfirm": "1234567891"
@@ -241,16 +99,16 @@ func TestAdminUpsertSubmit(t *testing.T) {
true, true,
}, },
{ {
// update succcess - new email // update success - new email
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", "sywbhecnh46rhm0",
`{ `{
"email": "test_update@example.com" "email": "test_update@example.com"
}`, }`,
false, false,
}, },
{ {
// update succcess - new password // update success - new password
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", "sywbhecnh46rhm0",
`{ `{
"password": "1234567890", "password": "1234567890",
"passwordConfirm": "1234567890" "passwordConfirm": "1234567890"
@@ -279,10 +137,10 @@ func TestAdminUpsertSubmit(t *testing.T) {
interceptorCalls := 0 interceptorCalls := 0
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { err := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func() error { return func(m *models.Admin) error {
interceptorCalls++ interceptorCalls++
return next() return next(m)
} }
}) })
@@ -338,16 +196,16 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
interceptorAdminEmail := "" interceptorAdminEmail := ""
interceptor1Called := false interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func() error { return func(m *models.Admin) error {
interceptor1Called = true interceptor1Called = true
return next() return next(m)
} }
} }
interceptor2Called := false interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
return func() error { return func(m *models.Admin) error {
interceptorAdminEmail = admin.Email // to check if the record was filled interceptorAdminEmail = admin.Email // to check if the record was filled
interceptor2Called = true interceptor2Called = true
return testErr return testErr
+13 -6
View File
@@ -2,23 +2,30 @@
// validation and applying changes to existing DB models through the app Dao. // validation and applying changes to existing DB models through the app Dao.
package forms package forms
import "regexp" import (
"regexp"
)
// base ID value regex pattern // base ID value regex pattern
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`) var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
// InterceptorNextFunc is a interceptor handler function. // InterceptorNextFunc is a interceptor handler function.
// Usually used in combination with InterceptorFunc. // Usually used in combination with InterceptorFunc.
type InterceptorNextFunc = func() error type InterceptorNextFunc[T any] func(t T) error
// InterceptorFunc defines a single interceptor function that will execute the provided next func handler. // InterceptorFunc defines a single interceptor function that
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc // will execute the provided next func handler.
type InterceptorFunc[T any] func(next InterceptorNextFunc[T]) InterceptorNextFunc[T]
// runInterceptors executes the provided list of interceptors. // runInterceptors executes the provided list of interceptors.
func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error { func runInterceptors[T any](
data T,
next InterceptorNextFunc[T],
interceptors ...InterceptorFunc[T],
) error {
for i := len(interceptors) - 1; i >= 0; i-- { for i := len(interceptors) - 1; i >= 0; i-- {
next = interceptors[i](next) next = interceptors[i](next)
} }
return next() return next(data)
} }
+272 -59
View File
@@ -1,6 +1,7 @@
package forms package forms
import ( import (
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -8,20 +9,25 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/types"
) )
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`) var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
// CollectionUpsert specifies a [models.Collection] upsert (create/update) form. // CollectionUpsert is a [models.Collection] upsert (create/update) form.
type CollectionUpsert struct { type CollectionUpsert struct {
config CollectionUpsertConfig app core.App
dao *daos.Dao
collection *models.Collection collection *models.Collection
Id string `form:"id" json:"id"` Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"` Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"` System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"` Schema schema.Schema `form:"schema" json:"schema"`
@@ -30,47 +36,25 @@ type CollectionUpsert struct {
CreateRule *string `form:"createRule" json:"createRule"` CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"` UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"` DeleteRule *string `form:"deleteRule" json:"deleteRule"`
} Options types.JsonMap `form:"options" json:"options"`
// CollectionUpsertConfig is the [CollectionUpsert] factory initializer config.
//
// NB! App is a required struct member.
type CollectionUpsertConfig struct {
App core.App
Dao *daos.Dao
} }
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer // NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
// config created from the provided [core.App] and [models.Collection] instances // config created from the provided [core.App] and [models.Collection] instances
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`). // (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
// //
// If you want to submit the form as part of another transaction, use // If you want to submit the form as part of a transaction,
// [NewCollectionUpsertWithConfig] with explicitly set Dao. // you can change the default Dao via [SetDao()].
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert { func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
App: app,
}, collection)
}
// NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form
// with the provided config and [models.Collection] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert {
form := &CollectionUpsert{ form := &CollectionUpsert{
config: config, app: app,
dao: app.Dao(),
collection: collection, collection: collection,
} }
if form.config.App == nil || form.collection == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.Dao == nil {
form.config.Dao = form.config.App.Dao()
}
// load defaults // load defaults
form.Id = form.collection.Id form.Id = form.collection.Id
form.Type = form.collection.Type
form.Name = form.collection.Name form.Name = form.collection.Name
form.System = form.collection.System form.System = form.collection.System
form.ListRule = form.collection.ListRule form.ListRule = form.collection.ListRule
@@ -78,9 +62,14 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
form.CreateRule = form.collection.CreateRule form.CreateRule = form.collection.CreateRule
form.UpdateRule = form.collection.UpdateRule form.UpdateRule = form.collection.UpdateRule
form.DeleteRule = form.collection.DeleteRule form.DeleteRule = form.collection.DeleteRule
form.Options = form.collection.Options
if form.Type == "" {
form.Type = models.CollectionTypeBase
}
clone, _ := form.collection.Schema.Clone() clone, _ := form.collection.Schema.Clone()
if clone != nil { if clone != nil && form.Type != models.CollectionTypeView {
form.Schema = *clone form.Schema = *clone
} else { } else {
form.Schema = schema.Schema{} form.Schema = schema.Schema{}
@@ -89,8 +78,25 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
return form 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. // Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error { func (form *CollectionUpsert) Validate() error {
isAuth := form.Type == models.CollectionTypeAuth
isView := form.Type == models.CollectionTypeView
// generate schema from the query (overwriting any explicit user defined schema)
if isView {
options := models.CollectionViewOptions{}
if err := decodeOptions(form.Options, &options); err != nil {
return err
}
form.Schema, _ = form.dao.CreateViewSchema(options.Query)
}
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
validation.Field( validation.Field(
&form.Id, &form.Id,
@@ -98,12 +104,23 @@ func (form *CollectionUpsert) Validate() error {
form.collection.IsNew(), form.collection.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength), validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex), validation.Match(idRegex),
validation.By(validators.UniqueId(form.dao, form.collection.TableName())),
).Else(validation.In(form.collection.Id)), ).Else(validation.In(form.collection.Id)),
), ),
validation.Field( validation.Field(
&form.System, &form.System,
validation.By(form.ensureNoSystemFlagChange), validation.By(form.ensureNoSystemFlagChange),
), ),
validation.Field(
&form.Type,
validation.Required,
validation.In(
models.CollectionTypeBase,
models.CollectionTypeAuth,
models.CollectionTypeView,
),
validation.By(form.ensureNoTypeChange),
),
validation.Field( validation.Field(
&form.Name, &form.Name,
validation.Required, validation.Required,
@@ -112,29 +129,51 @@ func (form *CollectionUpsert) Validate() error {
validation.By(form.ensureNoSystemNameChange), validation.By(form.ensureNoSystemNameChange),
validation.By(form.checkUniqueName), validation.By(form.checkUniqueName),
), ),
// validates using the type's own validation rules + some collection's specific // validates using the type's own validation rules + some collection's specifics
validation.Field( validation.Field(
&form.Schema, &form.Schema,
validation.By(form.checkMinSchemaFields),
validation.By(form.ensureNoSystemFieldsChange), validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange), validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureExistingRelationCollectionId), validation.By(form.checkRelationFields),
validation.When(isAuth, validation.By(form.ensureNoAuthFieldName)),
), ),
validation.Field(&form.ListRule, validation.By(form.checkRule)), validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)), validation.Field(&form.ViewRule, validation.By(form.checkRule)),
validation.Field(&form.CreateRule, validation.By(form.checkRule)), validation.Field(
validation.Field(&form.UpdateRule, validation.By(form.checkRule)), &form.CreateRule,
validation.Field(&form.DeleteRule, validation.By(form.checkRule)), validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.UpdateRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(
&form.DeleteRule,
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(&form.Options, validation.By(form.checkOptions)),
) )
} }
func (form *CollectionUpsert) checkUniqueName(value any) error { func (form *CollectionUpsert) checkUniqueName(value any) error {
v, _ := value.(string) v, _ := value.(string)
if !form.config.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).") return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
} }
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.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.") return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
} }
@@ -144,21 +183,31 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error { func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
v, _ := value.(string) v, _ := value.(string)
if form.collection.IsNew() || !form.collection.System || v == form.collection.Name { if !form.collection.IsNew() && form.collection.System && v != form.collection.Name {
return nil 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 { func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
v, _ := value.(bool) v, _ := value.(bool)
if form.collection.IsNew() || v == form.collection.System { if !form.collection.IsNew() && v != form.collection.System {
return nil 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 { func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
@@ -178,9 +227,17 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
return nil return nil
} }
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error { func (form *CollectionUpsert) checkRelationFields(value any) error {
v, _ := value.(schema.Schema) v, _ := value.(schema.Schema)
systemDisplayFields := schema.BaseModelFieldNames()
systemDisplayFields = append(systemDisplayFields,
schema.FieldNameUsername,
schema.FieldNameEmail,
schema.FieldNameEmailVisibility,
schema.FieldNameVerified,
)
for i, field := range v.Fields() { for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation { if field.Type != schema.FieldTypeRelation {
continue continue
@@ -188,14 +245,101 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
options, _ := field.Options.(*schema.RelationOptions) options, _ := field.Options.(*schema.RelationOptions)
if options == nil { if options == nil {
continue return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.NewError(
"validation_schema_invalid_relation_field_options",
"The relation field has invalid field options.",
)},
}
} }
if _, err := form.config.Dao.FindCollectionByNameOrId(options.CollectionId); err != nil { // prevent collectionId change
return validation.Errors{fmt.Sprint(i): validation.NewError( oldField := form.collection.Schema.GetFieldById(field.Id)
"validation_field_invalid_relation", if oldField != nil {
"The relation collection doesn't exist.", oldOptions, _ := oldField.Options.(*schema.RelationOptions)
)} if oldOptions != nil && oldOptions.CollectionId != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_relation_change",
"The relation collection cannot be changed.",
),
}},
}
}
}
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
// validate collectionId
if err != nil || collection.Id != options.CollectionId {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"collectionId": validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
),
}},
}
}
// validate displayFields (if any)
for _, name := range options.DisplayFields {
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
return validation.Errors{fmt.Sprint(i): validation.Errors{
"options": validation.Errors{
"displayFields": validation.NewError(
"validation_field_invalid_relation_displayFields",
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
),
}},
}
}
}
}
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 {
v, _ := value.(schema.Schema)
switch form.Type {
case models.CollectionTypeAuth, models.CollectionTypeView:
return nil // no schema fields constraint
default:
if len(v.Fields()) == 0 {
return validation.ErrRequired
} }
} }
@@ -222,17 +366,78 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
func (form *CollectionUpsert) checkRule(value any) error { func (form *CollectionUpsert) checkRule(value any) error {
v, _ := value.(*string) v, _ := value.(*string)
if v == nil || *v == "" { if v == nil || *v == "" {
return nil // nothing to check return nil // nothing to check
} }
dummy := &models.Collection{Schema: form.Schema} dummy := *form.collection
r := resolvers.NewRecordFieldResolver(form.config.Dao, dummy, nil) dummy.Type = form.Type
dummy.Schema = form.Schema
dummy.System = form.System
dummy.Options = form.Options
r := resolvers.NewRecordFieldResolver(form.dao, &dummy, nil, true)
_, err := search.FilterData(*v).BuildExpr(r) _, err := search.FilterData(*v).BuildExpr(r)
if err != nil { if err != nil {
return validation.NewError("validation_collection_rule", "Invalid filter rule.") return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
}
return nil
}
func (form *CollectionUpsert) checkOptions(value any) error {
v, _ := value.(types.JsonMap)
switch form.Type {
case models.CollectionTypeAuth:
options := models.CollectionAuthOptions{}
if err := decodeOptions(v, &options); err != nil {
return err
}
// 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}
}
case models.CollectionTypeView:
options := models.CollectionViewOptions{}
if err := decodeOptions(v, &options); err != nil {
return err
}
// check the generic validations
if err := options.Validate(); err != nil {
return err
}
// check the query option
if _, err := form.dao.CreateViewSchema(options.Query); err != nil {
return validation.Errors{
"query": validation.NewError(
"validation_invalid_view_query",
fmt.Sprintf("Invalid query - %s", err.Error()),
),
}
}
}
return nil
}
func decodeOptions(options types.JsonMap, result any) error {
raw, err := options.MarshalJSON()
if err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
}
if err := json.Unmarshal(raw, result); err != nil {
return validation.NewError("validation_invalid_options", "Invalid options.")
} }
return nil return nil
@@ -244,12 +449,15 @@ func (form *CollectionUpsert) checkRule(value any) error {
// //
// You can optionally provide a list of InterceptorFunc to further // You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it. // modify the form behavior before persisting it.
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error { func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Collection]) error {
if err := form.Validate(); err != nil { if err := form.Validate(); err != nil {
return err return err
} }
if form.collection.IsNew() { if form.collection.IsNew() {
// type can be set only on create
form.collection.Type = form.Type
// system flag can be set only on create // system flag can be set only on create
form.collection.System = form.System form.collection.System = form.System
@@ -265,14 +473,19 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.Name = form.Name form.collection.Name = form.Name
} }
form.collection.Schema = form.Schema // view schema is autogenerated on save
if !form.collection.IsView() {
form.collection.Schema = form.Schema
}
form.collection.ListRule = form.ListRule form.collection.ListRule = form.ListRule
form.collection.ViewRule = form.ViewRule form.collection.ViewRule = form.ViewRule
form.collection.CreateRule = form.CreateRule form.collection.CreateRule = form.CreateRule
form.collection.UpdateRule = form.UpdateRule form.collection.UpdateRule = form.UpdateRule
form.collection.DeleteRule = form.DeleteRule form.collection.DeleteRule = form.DeleteRule
form.collection.SetOptions(form.Options)
return runInterceptors(func() error { return runInterceptors(form.collection, func(collection *models.Collection) error {
return form.config.Dao.SaveCollection(form.collection) return form.dao.SaveCollection(collection)
}, interceptors...) }, interceptors...)
} }
+291 -189
View File
@@ -14,37 +14,15 @@ import (
"github.com/spf13/cast" "github.com/spf13/cast"
) )
func TestCollectionUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionUpsert(nil, nil)
}
func TestCollectionUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionUpsert(app, nil)
}
func TestNewCollectionUpsert(t *testing.T) { func TestNewCollectionUpsert(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
collection := &models.Collection{} collection := &models.Collection{}
collection.Name = "test" collection.Name = "test_name"
collection.Type = "test_type"
collection.System = true collection.System = true
listRule := "testview" listRule := "test_list"
collection.ListRule = &listRule collection.ListRule = &listRule
viewRule := "test_view" viewRule := "test_view"
collection.ViewRule = &viewRule collection.ViewRule = &viewRule
@@ -65,6 +43,10 @@ func TestNewCollectionUpsert(t *testing.T) {
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name) 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 { if form.System != collection.System {
t.Errorf("Expected System %v, got %v", collection.System, form.System) t.Errorf("Expected System %v, got %v", collection.System, form.System)
} }
@@ -104,95 +86,26 @@ func TestNewCollectionUpsert(t *testing.T) {
} }
} }
func TestCollectionUpsertValidate(t *testing.T) { func TestCollectionUpsertValidateAndSubmit(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) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
scenarios := []struct { scenarios := []struct {
testName string
existingName string existingName string
jsonData string jsonData string
expectedErrors []string expectedErrors []string
}{ }{
// empty create {"empty create (base)", "", "{}", []string{"name", "schema"}},
{"", "{}", []string{"name", "schema"}}, {"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
// empty update {"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
{"demo", "{}", []string{}}, {"empty update", "demo2", "{}", []string{}},
// create failure
{ {
"create failure",
"", "",
`{ `{
"name": "test ?!@#$", "name": "test ?!@#$",
"type": "invalid",
"system": true, "system": true,
"schema": [ "schema": [
{"name":"","type":"text"} {"name":"","type":"text"}
@@ -203,13 +116,13 @@ func TestCollectionUpsertSubmit(t *testing.T) {
"updateRule": "missing = '123'", "updateRule": "missing = '123'",
"deleteRule": "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, "system": true,
"schema": [ "schema": [
{"name":"test","type":"text"} {"name":"test","type":"text"}
@@ -222,19 +135,19 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`, }`,
[]string{"name"}, []string{"name"},
}, },
// create failure - existing internal table
{ {
"create failure - existing internal table",
"", "",
`{ `{
"name": "_users", "name": "_admins",
"schema": [ "schema": [
{"name":"test","type":"text"} {"name":"test","type":"text"}
] ]
}`, }`,
[]string{"name"}, []string{"name"},
}, },
// create failure - name starting with underscore
{ {
"create failure - name starting with underscore",
"", "",
`{ `{
"name": "_test_new", "name": "_test_new",
@@ -244,8 +157,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`, }`,
[]string{"name"}, []string{"name"},
}, },
// create failure - duplicated field names (case insensitive)
{ {
"create failure - duplicated field names (case insensitive)",
"", "",
`{ `{
"name": "test_new", "name": "test_new",
@@ -256,26 +169,77 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`, }`,
[]string{"schema"}, []string{"schema"},
}, },
// create success
{ {
"create failure - missing relation display field",
"", "",
`{ `{
"name": "test_new", "name": "test_new",
"type": "base",
"schema": [
{
"name":"test",
"type":"relation",
"options":{
"collectionId":"wsmn24bux7wo113",
"displayFields":["text", "missing"]
}
}
]
}`,
[]string{"schema"},
},
{
"create failure - check auth options validators",
"",
`{
"name": "test_new",
"type": "auth",
"schema": [
{"name":"test","type":"text"}
],
"options": { "minPasswordLength": 3 }
}`,
[]string{"options"},
},
{
"create failure - check view options validators",
"",
`{
"name": "test_new",
"type": "view",
"options": { "query": "invalid query" }
}`,
[]string{"options"},
},
{
"create success",
"",
`{
"name": "test_new",
"type": "auth",
"system": true, "system": true,
"schema": [ "schema": [
{"id":"a123456","name":"test1","type":"text"}, {"id":"a123456","name":"test1","type":"text"},
{"id":"b123456","name":"test2","type":"email"} {"id":"b123456","name":"test2","type":"email"},
{
"name":"test3",
"type":"relation",
"options":{
"collectionId":"v851q4r790rhknl",
"displayFields":["name","id","created","updated","username","email","emailVisibility","verified"]
}
}
], ],
"listRule": "test1='123'", "listRule": "test1='123' && verified = true",
"viewRule": "test1='123'", "viewRule": "test1='123' && emailVisibility = true",
"createRule": "test1='123'", "createRule": "test1='123' && email != ''",
"updateRule": "test1='123'", "updateRule": "test1='123' && username != ''",
"deleteRule": "test1='123'" "deleteRule": "test1='123' && id != ''"
}`, }`,
[]string{}, []string{},
}, },
// update failure - changing field type
{ {
"update failure - changing field type",
"test_new", "test_new",
`{ `{
"schema": [ "schema": [
@@ -285,8 +249,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`, }`,
[]string{"schema"}, []string{"schema"},
}, },
// update success - rename fields to existing field names (aka. reusing field names)
{ {
"update success - rename fields to existing field names (aka. reusing field names)",
"test_new", "test_new",
`{ `{
"schema": [ "schema": [
@@ -296,34 +260,63 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`, }`,
[]string{}, []string{},
}, },
// update failure - existing name
{ {
"demo", "update failure - existing name",
`{"name": "demo2"}`, "demo2",
`{"name": "demo3"}`,
[]string{"name"}, []string{"name"},
}, },
// update failure - changing system collection
{ {
models.ProfileCollectionName, "update failure - changing system collection",
"nologin",
`{ `{
"name": "update", "name": "update",
"system": false, "system": false,
"schema": [ "schema": [
{"id":"koih1lqx","name":"userId","type":"text"} {"id":"koih1lqx","name":"abc","type":"text"}
], ],
"listRule": "userId = '123'", "listRule": "abc = '123'",
"viewRule": "userId = '123'", "viewRule": "abc = '123'",
"createRule": "userId = '123'", "createRule": "abc = '123'",
"updateRule": "userId = '123'", "updateRule": "abc = '123'",
"deleteRule": "userId = '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 - changing relation collection",
"users",
`{
"schema": [
{
"id": "lkeigvv3",
"name": "rel",
"type": "relation",
"options": {
"collectionId": "wzlqyes4orhoygb",
"cascadeDelete": false,
"maxSelect": 1,
"displayFields": null
}
}
]
}`,
[]string{"schema"},
},
{
"update failure - all fields",
"demo2",
`{ `{
"name": "test ?!@#$", "name": "test ?!@#$",
"type": "invalid",
"system": true, "system": true,
"schema": [ "schema": [
{"name":"","type":"text"} {"name":"","type":"text"}
@@ -332,29 +325,32 @@ func TestCollectionUpsertSubmit(t *testing.T) {
"viewRule": "missing = '123'", "viewRule": "missing = '123'",
"createRule": "missing = '123'", "createRule": "missing = '123'",
"updateRule": "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", "name": "demo_update",
"type": "auth",
"schema": [ "schema": [
{"id":"_2hlxbmp","name":"test","type":"text"} {"id":"_2hlxbmp","name":"test","type":"text"}
], ],
"listRule": "test='123'", "listRule": "test='123' && verified = true",
"viewRule": "test='123'", "viewRule": "test='123' && emailVisibility = true",
"createRule": "test='123'", "createRule": "test='123' && email != ''",
"updateRule": "test='123'", "updateRule": "test='123' && username != ''",
"deleteRule": "test='123'" "deleteRule": "test='123' && id != ''",
"options": {"minPasswordLength": 10}
}`, }`,
[]string{}, []string{},
}, },
// update failure - rename the schema field of the last updated collection
// (fail due to filters old field references) // (fail due to filters old field references)
{ {
"update failure - rename the schema field of the last updated collection",
"demo_update", "demo_update",
`{ `{
"schema": [ "schema": [
@@ -363,9 +359,9 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`, }`,
[]string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
}, },
// update success - rename the schema field of the last updated collection
// (cleared filter references) // (cleared filter references)
{ {
"update success - rename the schema field of the last updated collection",
"demo_update", "demo_update",
`{ `{
"schema": [ "schema": [
@@ -379,21 +375,114 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`, }`,
[]string{}, []string{},
}, },
// update success - system collection
{ {
models.ProfileCollectionName, "update success - system collection",
"nologin",
`{ `{
"listRule": "userId='123'", "listRule": "name='123'",
"viewRule": "userId='123'", "viewRule": "name='123'",
"createRule": "userId='123'", "createRule": "name='123'",
"updateRule": "userId='123'", "updateRule": "name='123'",
"deleteRule": "userId='123'" "deleteRule": "name='123'"
}`,
[]string{},
},
// view tests
// -----------------------------------------------------------
{
"view create failure",
"",
`{
"name": "upsert_view",
"type": "view",
"listRule": "id='123' && verified = true",
"viewRule": "id='123' && emailVisibility = true",
"schema": [
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
],
"options": {
"query": "select id, email from users; drop table _admins;"
}
}`,
[]string{
"listRule",
"viewRule",
"options",
},
},
{
"view create success",
"",
`{
"name": "upsert_view",
"type": "view",
"listRule": "id='123' && verified = true",
"viewRule": "id='123' && emailVisibility = true",
"schema": [
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
],
"options": {
"query": "select id, emailVisibility, verified from users"
}
}`,
[]string{
// "schema", should be overwritten by an autogenerated from the query
},
},
{
"view update failure (schema autogeneration and rule fields check)",
"upsert_view",
`{
"name": "upsert_view_2",
"listRule": "id='456' && verified = true",
"viewRule": "id='456'",
"createRule": "id='123'",
"updateRule": "id='123'",
"deleteRule": "id='123'",
"schema": [
{"id":"abc123","name":"verified","type":"bool"}
],
"options": {
"query": "select 1 as id"
}
}`,
[]string{
"listRule", // missing field (ignoring the old or explicit schema)
"createRule", // not allowed
"updateRule", // not allowed
"deleteRule", // not allowed
},
},
{
"view update failure (check query identifiers format)",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as [invalid!@#]"
}
}`,
[]string{
"schema", // should fail due to invalid field name
},
},
{
"view update success",
"upsert_view",
`{
"listRule": null,
"viewRule": null,
"options": {
"query": "select 1 as id, 2 as valid"
}
}`, }`,
[]string{}, []string{},
}, },
} }
for i, s := range scenarios { for _, s := range scenarios {
collection := &models.Collection{} collection := &models.Collection{}
if s.existingName != "" { if s.existingName != "" {
var err error var err error
@@ -408,15 +497,15 @@ func TestCollectionUpsertSubmit(t *testing.T) {
// load data // load data
loadErr := json.Unmarshal([]byte(s.jsonData), form) loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil { if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr) t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
continue continue
} }
interceptorCalls := 0 interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func() error { return func(c *models.Collection) error {
interceptorCalls++ interceptorCalls++
return next() return next(c)
} }
} }
@@ -424,26 +513,26 @@ func TestCollectionUpsertSubmit(t *testing.T) {
result := form.Submit(interceptor) result := form.Submit(interceptor)
errs, ok := result.(validation.Errors) errs, ok := result.(validation.Errors)
if !ok && result != nil { 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 continue
} }
// check interceptor calls // check interceptor calls
expectInterceptorCall := 1 expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 { if len(s.expectedErrors) > 0 {
expectInterceptorCall = 0 expectInterceptorCalls = 0
} }
if interceptorCalls != expectInterceptorCall { if interceptorCalls != expectInterceptorCalls {
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls) t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
} }
// check errors // check errors
if len(errs) > len(s.expectedErrors) { 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 { for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok { 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)
} }
} }
@@ -453,42 +542,55 @@ func TestCollectionUpsertSubmit(t *testing.T) {
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name) collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
if collection == nil { 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 continue
} }
if form.Name != collection.Name { 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 { 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) { 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) { 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) { 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) { 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) { 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() rawFormSchema, _ := form.Schema.MarshalJSON()
collectionSchema, _ := collection.Schema.MarshalJSON() rawCollectionSchema, _ := collection.Schema.MarshalJSON()
if string(formSchema) != string(collectionSchema) {
t.Errorf("(%d) Expected Schema %v, got %v", i, string(collectionSchema), string(formSchema)) if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
continue
}
for _, f := range form.Schema.Fields() {
if collection.Schema.GetFieldByName(f.Name) == nil {
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
continue
}
} }
} }
} }
@@ -497,7 +599,7 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo") collection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -509,16 +611,16 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
interceptorCollectionName := "" interceptorCollectionName := ""
interceptor1Called := false interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { interceptor1 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func() error { return func(c *models.Collection) error {
interceptor1Called = true interceptor1Called = true
return next() return next(c)
} }
} }
interceptor2Called := false interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { interceptor2 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
return func() error { return func(c *models.Collection) error {
interceptorCollectionName = collection.Name // to check if the record was filled interceptorCollectionName = collection.Name // to check if the record was filled
interceptor2Called = true interceptor2Called = true
return testErr return testErr
@@ -547,14 +649,14 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo3") existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
newCollection := func() *models.Collection { newCollection := func() *models.Collection {
return &models.Collection{ return &models.Collection{
Name: "c_" + security.RandomString(4), Name: "c_" + security.PseudorandomString(4),
Schema: existingCollection.Schema, Schema: existingCollection.Schema,
} }
} }
@@ -621,27 +723,27 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
}, },
} }
for _, scenario := range scenarios { for _, s := range scenarios {
form := forms.NewCollectionUpsert(app, scenario.collection) form := forms.NewCollectionUpsert(app, s.collection)
// load data // load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form) loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil { if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr) t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue continue
} }
submitErr := form.Submit() submitErr := form.Submit()
hasErr := submitErr != nil hasErr := submitErr != nil
if hasErr != scenario.expectError { if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr) t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
} }
if !hasErr && form.Id != "" { if !hasErr && form.Id != "" {
_, err := app.Dao().FindCollectionByNameOrId(form.Id) _, err := app.Dao().FindCollectionByNameOrId(form.Id)
if err != nil { if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err) t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err)
} }
} }
} }
+23 -39
View File
@@ -11,48 +11,31 @@ import (
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
) )
// CollectionsImport specifies a form model to bulk import // CollectionsImport is a form model to bulk import
// (create, replace and delete) collections from a user provided list. // (create, replace and delete) collections from a user provided list.
type CollectionsImport struct { type CollectionsImport struct {
config CollectionsImportConfig app core.App
dao *daos.Dao
Collections []*models.Collection `form:"collections" json:"collections"` Collections []*models.Collection `form:"collections" json:"collections"`
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"` DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
} }
// CollectionsImportConfig is the [CollectionsImport] factory initializer config.
//
// NB! App is a required struct member.
type CollectionsImportConfig struct {
App core.App
Dao *daos.Dao
}
// NewCollectionsImport creates a new [CollectionsImport] form with // NewCollectionsImport creates a new [CollectionsImport] form with
// initializer config created from the provided [core.App] instance. // initialized with from the provided [core.App] instance.
// //
// If you want to submit the form as part of another transaction, use // If you want to submit the form as part of a transaction,
// [NewCollectionsImportWithConfig] with explicitly set Dao. // you can change the default Dao via [SetDao()].
func NewCollectionsImport(app core.App) *CollectionsImport { func NewCollectionsImport(app core.App) *CollectionsImport {
return NewCollectionsImportWithConfig(CollectionsImportConfig{ return &CollectionsImport{
App: app, app: app,
}) dao: app.Dao(),
}
} }
// NewCollectionsImportWithConfig creates a new [CollectionsImport] // SetDao replaces the default form Dao instance with the provided one.
// form with the provided config or panics on invalid configuration. func (form *CollectionsImport) SetDao(dao *daos.Dao) {
func NewCollectionsImportWithConfig(config CollectionsImportConfig) *CollectionsImport { form.dao = dao
form := &CollectionsImport{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.Dao == nil {
form.config.Dao = form.config.App.Dao()
}
return form
} }
// Validate makes the form validatable by implementing [validation.Validatable] interface. // Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -73,15 +56,15 @@ func (form *CollectionsImport) Validate() error {
// //
// You can optionally provide a list of InterceptorFunc to further // You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it. // modify the form behavior before persisting it.
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error { func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.Collection]) error {
if err := form.Validate(); err != nil { if err := form.Validate(); err != nil {
return err return err
} }
return runInterceptors(func() error { return runInterceptors(form.Collections, func(collections []*models.Collection) error {
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error { return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
importErr := txDao.ImportCollections( importErr := txDao.ImportCollections(
form.Collections, collections,
form.DeleteMissing, form.DeleteMissing,
form.beforeRecordsSync, form.beforeRecordsSync,
) )
@@ -95,7 +78,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
} }
// generic/db failure // generic/db failure
if form.config.App.IsDebug() { if form.app.IsDebug() {
log.Println("Internal import failure:", importErr) log.Println("Internal import failure:", importErr)
} }
return validation.Errors{"collections": validation.NewError( return validation.Errors{"collections": validation.NewError(
@@ -120,14 +103,14 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
if upsertModel == nil { if upsertModel == nil {
upsertModel = collection upsertModel = collection
} }
upsertModel.MarkAsNotNew()
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{ upsertForm := NewCollectionUpsert(form.app, upsertModel)
App: form.config.App, upsertForm.SetDao(txDao)
Dao: txDao,
}, upsertModel)
// load form fields with the refreshed collection state // load form fields with the refreshed collection state
upsertForm.Id = collection.Id upsertForm.Id = collection.Id
upsertForm.Type = collection.Type
upsertForm.Name = collection.Name upsertForm.Name = collection.Name
upsertForm.System = collection.System upsertForm.System = collection.System
upsertForm.ListRule = collection.ListRule upsertForm.ListRule = collection.ListRule
@@ -136,6 +119,7 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
upsertForm.UpdateRule = collection.UpdateRule upsertForm.UpdateRule = collection.UpdateRule
upsertForm.DeleteRule = collection.DeleteRule upsertForm.DeleteRule = collection.DeleteRule
upsertForm.Schema = collection.Schema upsertForm.Schema = collection.Schema
upsertForm.Options = collection.Options
if err := upsertForm.Validate(); err != nil { if err := upsertForm.Validate(); err != nil {
// serialize the validation error(s) // serialize the validation error(s)
+81 -64
View File
@@ -10,16 +10,6 @@ import (
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
) )
func TestCollectionsImportPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionsImport(nil)
}
func TestCollectionsImportValidate(t *testing.T) { func TestCollectionsImportValidate(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -48,6 +38,8 @@ func TestCollectionsImportValidate(t *testing.T) {
} }
func TestCollectionsImportSubmit(t *testing.T) { func TestCollectionsImportSubmit(t *testing.T) {
totalCollections := 10
scenarios := []struct { scenarios := []struct {
name string name string
jsonData string jsonData string
@@ -62,7 +54,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": [] "collections": []
}`, }`,
expectError: true, expectError: true,
expectCollectionsCount: 5, expectCollectionsCount: totalCollections,
expectEvents: nil, expectEvents: nil,
}, },
{ {
@@ -92,7 +84,26 @@ func TestCollectionsImportSubmit(t *testing.T) {
] ]
}`, }`,
expectError: true, expectError: true,
expectCollectionsCount: 5, expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
},
{
name: "test empty base collection schema",
jsonData: `{
"collections": [
{
"name": "import1"
},
{
"name": "import2",
"type": "auth"
}
]
}`,
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{ expectEvents: map[string]int{
"OnModelBeforeCreate": 2, "OnModelBeforeCreate": 2,
}, },
@@ -120,14 +131,18 @@ func TestCollectionsImportSubmit(t *testing.T) {
"type":"bool" "type":"bool"
} }
] ]
},
{
"name": "import3",
"type": "auth"
} }
] ]
}`, }`,
expectError: false, expectError: false,
expectCollectionsCount: 7, expectCollectionsCount: totalCollections + 3,
expectEvents: map[string]int{ expectEvents: map[string]int{
"OnModelBeforeCreate": 2, "OnModelBeforeCreate": 3,
"OnModelAfterCreate": 2, "OnModelAfterCreate": 3,
}, },
}, },
{ {
@@ -147,7 +162,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
] ]
}`, }`,
expectError: true, expectError: true,
expectCollectionsCount: 5, expectCollectionsCount: totalCollections,
expectEvents: map[string]int{ expectEvents: map[string]int{
"OnModelBeforeCreate": 1, "OnModelBeforeCreate": 1,
}, },
@@ -158,8 +173,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
"deleteMissing": true, "deleteMissing": true,
"collections": [ "collections": [
{ {
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc", "id":"sz5l5z67tg7gku0",
"name":"demo", "name":"demo2",
"schema":[ "schema":[
{ {
"id":"_2hlxbmp", "id":"_2hlxbmp",
@@ -189,19 +204,22 @@ func TestCollectionsImportSubmit(t *testing.T) {
] ]
}`, }`,
expectError: true, expectError: true,
expectCollectionsCount: 5, expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 5,
},
}, },
{ {
name: "modified + new collection", name: "modified + new collection",
jsonData: `{ jsonData: `{
"collections": [ "collections": [
{ {
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc", "id":"sz5l5z67tg7gku0",
"name":"demo", "name":"demo2",
"schema":[ "schema":[
{ {
"id":"_2hlxbmp", "id":"_2hlxbmp",
"name":"title", "name":"title_new",
"type":"text", "type":"text",
"system":false, "system":false,
"required":true, "required":true,
@@ -237,7 +255,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
] ]
}`, }`,
expectError: false, expectError: false,
expectCollectionsCount: 7, expectCollectionsCount: totalCollections + 2,
expectEvents: map[string]int{ expectEvents: map[string]int{
"OnModelBeforeUpdate": 1, "OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1, "OnModelAfterUpdate": 1,
@@ -251,45 +269,44 @@ func TestCollectionsImportSubmit(t *testing.T) {
"deleteMissing": true, "deleteMissing": true,
"collections": [ "collections": [
{ {
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b", "id": "kpv709sk2lqbqk8",
"name":"profiles", "system": true,
"system":true, "name": "nologin",
"listRule":"userId = @request.user.id", "type": "auth",
"viewRule":"created > 'test_change'", "options": {
"createRule":"userId = @request.user.id", "allowEmailAuth": false,
"updateRule":"userId = @request.user.id", "allowOAuth2Auth": false,
"deleteRule":"userId = @request.user.id", "allowUsernameAuth": false,
"schema":[ "exceptEmailDomains": [],
"manageRule": "@request.auth.collectionName = 'users'",
"minPasswordLength": 8,
"onlyEmailDomains": [],
"requireEmail": true
},
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"schema": [
{ {
"id":"koih1lqx", "id": "x8zzktwe",
"name":"userId", "name": "name",
"type":"user", "type": "text",
"system":true, "system": false,
"required":true, "required": false,
"unique":true, "unique": false,
"options":{ "options": {
"maxSelect":1, "min": null,
"cascadeDelete":true "max": null,
} "pattern": ""
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
} }
} }
] ]
}, },
{ {
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc", "id":"sz5l5z67tg7gku0",
"name":"demo", "name":"demo2",
"schema":[ "schema":[
{ {
"id":"_2hlxbmp", "id":"_2hlxbmp",
@@ -308,7 +325,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
}, },
{ {
"id": "test_deleted_collection_name_reuse", "id": "test_deleted_collection_name_reuse",
"name": "demo2", "name": "demo1",
"schema": [ "schema": [
{ {
"id":"fz6iql2m", "id":"fz6iql2m",
@@ -326,8 +343,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
"OnModelAfterUpdate": 2, "OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1, "OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1, "OnModelAfterCreate": 1,
"OnModelBeforeDelete": 3, "OnModelBeforeDelete": totalCollections - 2,
"OnModelAfterDelete": 3, "OnModelAfterDelete": totalCollections - 2,
}, },
}, },
} }
@@ -389,16 +406,16 @@ func TestCollectionsImportSubmitInterceptors(t *testing.T) {
testErr := errors.New("test_error") testErr := errors.New("test_error")
interceptor1Called := false interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { interceptor1 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func() error { return func(imports []*models.Collection) error {
interceptor1Called = true interceptor1Called = true
return next() return next(imports)
} }
} }
interceptor2Called := false interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { interceptor2 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
return func() error { return func(imports []*models.Collection) error {
interceptor2Called = true interceptor2Called = true
return testErr return testErr
} }
+143
View File
@@ -0,0 +1,143 @@
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 InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*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 := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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 InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
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")
}
}
+270
View File
@@ -0,0 +1,270 @@
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"
)
// RecordOAuth2LoginData defines the OA
type RecordOAuth2LoginData struct {
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
}
// BeforeOAuth2RecordCreateFunc defines a callback function that will
// be called before OAuth2 new Record creation.
type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error
// RecordOAuth2Login is an auth record OAuth2 login form.
type RecordOAuth2Login struct {
app core.App
dao *daos.Dao
collection *models.Collection
beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc
// 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
}
// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
form.beforeOAuth2RecordCreateFunc = f
}
// 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 Record create form with [form.SetBeforeNewRecordCreateFunc()].
//
// You can also optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
//
// On success returns the authorized record model and the fetched provider's data.
func (form *RecordOAuth2Login) Submit(
interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
) (*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)
}
interceptorData := &RecordOAuth2LoginData{
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
}
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
return form.submit(newData)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorData.OAuth2User, interceptorsErr
}
return interceptorData.Record, interceptorData.OAuth2User, nil
}
func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
if data.Record == nil {
data.Record = models.NewRecord(form.collection)
data.Record.RefreshId()
data.Record.MarkAsNew()
createForm := NewRecordUpsert(form.app, data.Record)
createForm.SetFullManageAccess(true)
createForm.SetDao(txDao)
if data.OAuth2User.Username != "" &&
len(data.OAuth2User.Username) >= 3 &&
len(data.OAuth2User.Username) <= 150 &&
usernameRegex.MatchString(data.OAuth2User.Username) {
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
form.collection.Id,
data.OAuth2User.Username,
)
}
// load custom data
createForm.LoadData(form.CreateData)
// load the OAuth2 profile data as fallback
if createForm.Email == "" {
createForm.Email = data.OAuth2User.Email
}
createForm.Verified = false
if createForm.Email == data.OAuth2User.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
}
if form.beforeOAuth2RecordCreateFunc != nil {
if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); 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 data.OAuth2User has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
if data.Record.Email() == "" && data.OAuth2User.Email != "" {
data.Record.SetEmail(data.OAuth2User.Email)
if err := txDao.SaveRecord(data.Record); 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 data.OAuth2User)
if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
data.Record.SetVerified(true)
if err := txDao.SaveRecord(data.Record); err != nil {
return err
}
}
}
// create ExternalAuth relation if missing
if data.ExternalAuth == nil {
data.ExternalAuth = &models.ExternalAuth{
CollectionId: data.Record.Collection().Id,
RecordId: data.Record.Id,
Provider: form.Provider,
ProviderId: data.OAuth2User.Id,
}
if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
return err
}
}
return nil
})
}
@@ -9,55 +9,60 @@ import (
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
) )
func TestUserOauth2LoginPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserOauth2Login(nil)
}
func TestUserOauth2LoginValidate(t *testing.T) { func TestUserOauth2LoginValidate(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
scenarios := []struct { scenarios := []struct {
testName string
collectionName string
jsonData string jsonData string
expectedErrors []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":""}`, `{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
[]string{"provider", "code", "codeVerifier", "redirectUrl"}, []string{"provider", "code", "codeVerifier", "redirectUrl"},
}, },
// missing provider
{ {
"missing provider",
"users",
`{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, `{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{"provider"}, []string{"provider"},
}, },
// disabled provider
{ {
"disabled provider",
"users",
`{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, `{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{"provider"}, []string{"provider"},
}, },
// enabled provider
{ {
"enabled provider",
"users",
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, `{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
[]string{}, []string{},
}, },
} }
for i, s := range scenarios { for _, s := range scenarios {
form := forms.NewUserOauth2Login(app) 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 // load data
loadErr := json.Unmarshal([]byte(s.jsonData), form) loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil { 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 continue
} }
@@ -66,17 +71,17 @@ func TestUserOauth2LoginValidate(t *testing.T) {
// parse errors // parse errors
errs, ok := err.(validation.Errors) errs, ok := err.(validation.Errors)
if !ok && err != nil { 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 continue
} }
// check errors // check errors
if len(errs) > len(s.expectedErrors) { 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 { for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok { 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)
} }
} }
} }
+95
View File
@@ -0,0 +1,95 @@
package forms
import (
"database/sql"
"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.
//
// You can optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordLogin) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
authOptions := form.collection.AuthOptions()
var authRecord *models.Record
var fetchErr error
isEmail := is.EmailFormat.Validate(form.Identity) == nil
if isEmail {
if authOptions.AllowEmailAuth {
authRecord, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
}
} else if authOptions.AllowUsernameAuth {
authRecord, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
}
// ignore not found errors to allow custom fetch implementations
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
return nil, fetchErr
}
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
if authRecord == nil || !authRecord.ValidatePassword(form.Password) {
return errors.New("Invalid login credentials.")
}
return nil
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return authRecord, nil
}
+182
View File
@@ -0,0 +1,182 @@
package forms_test
import (
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestRecordPasswordLoginValidateAndSubmit(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)
}
}
}
func TestRecordPasswordLoginInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordPasswordLogin(testApp, authCollection)
form.Identity = "test@example.com"
form.Password = "123456"
var interceptorRecord *models.Record
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
return func(record *models.Record) error {
interceptorRecord = record
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 interceptorRecord == nil || interceptorRecord.Email() != form.Identity {
t.Fatalf("Expected auth Record model with email %s, got %v", form.Identity, interceptorRecord)
}
}
+104
View File
@@ -0,0 +1,104 @@
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 InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*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 := runInterceptors(authRecord, func(m *models.Record) error {
authRecord = m
return form.dao.SaveRecord(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return authRecord, nil
}
Loaded 100 of 701 files, more files were not shown because too many files have changed in this diff. Show more